Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

siggy

A terminal-based Signal messenger client with an IRC aesthetic.

siggy wraps signal-cli via JSON-RPC, giving you a full-featured messaging interface that runs entirely in your terminal. Built with Ratatui, Crossterm, and Tokio.

Why siggy?

  • Lightweight – no Electron, no web browser, just your terminal
  • Vim keybindings – modal editing with Normal and Insert modes
  • Persistent – SQLite-backed message history that survives restarts
  • Private – incognito mode for ephemeral sessions with zero disk traces
  • Extensible – TOML configuration, slash commands, and a clean module architecture

Quick start

From crates.io:

cargo install siggy

Linux / macOS:

curl -fsSL https://raw.githubusercontent.com/johnsideserf/siggy/master/install.sh | bash

Windows (PowerShell):

irm https://raw.githubusercontent.com/johnsideserf/siggy/master/install.ps1 | iex

Then launch:

siggy

The setup wizard will guide you through linking your Signal account on first launch.

Try it without Signal

siggy --demo

Demo mode populates the UI with dummy conversations and messages so you can explore the interface without a Signal account or signal-cli installed.

License

AGPL-3.0

Changelog

v1.14.2

Patch release for cargo install siggy failing on Rust toolchains older than 1.95 (#668).

Fixed

  • cargo install siggy no longer requires a bleeding-edge Rust toolchain. v1.14.1 depended on rusqlite 0.40, whose libsqlite3-sys 0.38.1 build script uses the cfg_select! macro stabilized only in Rust 1.95 (April 2026), failing on older toolchains with an opaque E0658 error. siggy now builds against rusqlite 0.38 / libsqlite3-sys 0.36, and declares an explicit rust-version = "1.90" (verified in CI) so a too-old toolchain gets a clear, actionable error from cargo instead.

v1.14.1

Patch release for the broken proxy config (#656).

Fixed

  • Removed the proxy config field; siggy now refuses to start while it is set. The field was passed to signal-cli as --proxy, a flag that does not exist in any signal-cli version, so it never worked: with a proxy configured the message process died at startup (no messages, spurious relink prompts), while linking and --check probes connected to Signal directly, unproxied. signal-cli has no proxy support of any kind, so the field is gone; if you had it set, siggy exits with an explanation and pointers to OS-level alternatives (JVM proxy properties via JAVA_TOOL_OPTIONS, proxychains). If you relied on this setting for privacy, be aware your traffic was never proxied.

v1.14.0

The foundation release for the native backend epic (#637): an internal backend-boundary refactor with no intended user-facing changes beyond the items below. This release soaks before any native engine code merges, so anything odd is worth reporting.

Changed

  • License: GPL-3.0 to AGPL-3.0-only. The planned native Signal engine builds on presage and Signal’s libsignal (both AGPL), and the relicense lands ahead of that code. AGPL keeps every GPL obligation and adds the network-source clause.
  • siggy --check now actually checks. It previously reported ready without verifying registration; it now probes the account’s link state, prints the backend engine and a link: line, and exits nonzero with a pointer to siggy --setup when the account is not linked.
  • Replay protection. Incoming messages are deduplicated at the database level (new entry_seq schema column and unique index), so replayed envelopes after a reconnect can no longer duplicate messages, inflate unread counts, unarchive conversations, or re-fire notifications and message triggers.

Internal

  • The messaging engine now lives behind a Backend trait in src/backend/ (signal-cli adapter, demo adapter, test mock), selected at compile time by mutually exclusive Cargo features (signal-cli-backend, the default, and the not-yet-implemented native-backend).
  • Send correlation, link state, connection events, and sync completion are backend-neutral vocabulary types; linking, startup registration checks, and reconnect supervision route through the boundary.
  • 30 characterization tests lock the signal-cli backend’s observable behavior as the refactor’s regression gate.
  • Schema migration to v16 (entry_seq backfill + dedup index), verified against a real database copy before shipping.
  • +89 tests since v1.13.0 (1,146 total).

v1.13.0

The automation and identity release: scriptable message triggers with a headless watch mode, and Signal username (@handle) support.

New features

  • Message triggers – declarative rules in triggers.toml (next to config.toml) fire on incoming messages: auto-reply with a canned text, or run an external command that receives the message as JSON on stdin. Matching is dependency-free (case-insensitive substring, exact, or prefix), rules can be scoped by sender and conversation, and /triggers reloads the file live. Safety rails: run commands are argv arrays spawned without a shell and require a top-level allow_run = true opt-in, message content only ever reaches the child as JSON (injection-proof), history replayed during initial sync never fires, your own messages never fire, and a per-rule per-conversation rate limit (default 30s) guards against auto-responder loops (closes #615).
  • Headless watch modesiggy --watch runs the same trigger rules as a standalone bot with no TUI, logging one line per firing. Note signal-cli allows one process per account, so --watch and the TUI are mutually exclusive.
  • Signal usernames – @handle contacts are now first-class: username- only contacts (no shared phone number) appear in the contacts overlay as @handle instead of being dropped, /join @name.123 resolves unknown handles through the server, the chat header shows Name (@handle) behind a new Show usernames setting, and siggy --send @name.123 works from the CLI (closes #612).

Internal

  • New trigger module (engine, TOML rules, evaluation rails) shared by the TUI and --watch hosts.
  • Contact.number is now optional; conversations for username-only contacts key by ACI uuid, consistent with the existing envelope fallback.
  • +37 tests since v1.12.0 (1,057 total).

v1.12.0

The power-user release: a fuzzy command palette, sender-generated link previews, voice playback progress, inline stickers, and the long-awaited Sixel tuning contribution.

New features

  • Command paletteCtrl+P opens a fuzzy finder over conversations and slash commands in one list. Type to filter (fuzzy subsequence matching, so names containing j/k stay typeable; navigate with Up/Down), Enter jumps to the conversation, runs argument-less commands immediately, or prefills the composer for commands that take arguments. Bound in the Default and Minimal profiles; bindable as command_palette in Emacs, where Ctrl+P stays line-up (closes #614).
  • Outgoing link previews/preview <url> fetches the page in the background, extracts Open Graph metadata (title, description, thumbnail), and attaches the preview card to your next message; /preview with no argument discards it. Previews are never fetched automatically while typing: fetching a URL reveals your IP to that site, so it only happens on the explicit command (closes #267).
  • Voice playback progress – voice labels show the note’s length ([voice ▶ name 0:12], parsed from the Ogg Opus container with no decoder dependency), the status bar ticks live progress while playing, pressing o on the playing message stops it, and quitting siggy kills the player (closes #618).
  • Inline stickers – stickers render as inline images through the regular image pipeline when signal-cli has the pack cached locally, falling back to the [Sticker: emoji] placeholder otherwise. Wire- controlled pack ids are hex-validated as a path-traversal guard (closes #610).
  • Sixel image tuning – image sizing config (image_max_width, preview_image_max_width, image_max_height) with a no-upscale clamp against the actual pane width, plus Sixel encode options (sixel_max_colors, sixel_diffusion) and browser-terminal flicker improvements. A blank guard row below each Sixel image keeps sub-row pixel overflow from bleeding into the next text line. Thanks to @justinledwards for this contribution (closes #520).

Internal

  • New dependency: ureq (minimal HTTP client) for /preview fetches.
  • New shared fuzzy scorer in list_overlay; the loose sidebar-filter fields moved into a domain sub-struct.
  • +100 tests since v1.11.0 (1,020 total).

v1.11.0

The messaging-parity release: voice messages play inline, you can compose formatted text, exports come in three formats, spoilers reveal on focus, and conversations can be archived or flipped back to unread.

New features

  • Voice message playback – audio attachments show as [voice ▶ name]; press o on the focused message to play inline through a detected CLI player (mpv, ffplay, afplay, cvlc, paplay, or aplay). A new audio_player config option overrides autodetection (e.g. audio_player = "mpv --no-config"). Falls back to the OS default app when no player is installed (closes #199, #619).
  • Compose text formatting – WhatsApp-style markers convert to Signal style ranges on send: *bold*, _italic_, ~strikethrough~, `monospace`, ||spoiler||. Boundary rules keep snake_case, 2 * 3, and URLs with underscores untouched; works for edits too (closes #609).
  • Export formats/export [txt|md|json] [n] exports the active conversation as plain text (default), Markdown, or JSON for scripting. Reactions are now included in all formats (closes #613).
  • Spoiler reveal on focus – spoiler text stays masked behind block characters until you focus the message with J/K; moving focus away re-masks it (closes #616).
  • Archive/archive hides the current conversation from the sidebar; any new message unarchives it, the sidebar filter (/_) lists archived conversations, and a footer row shows the hidden count (closes #611).
  • Mark as unread/unread flips a read conversation back to unread (badge + persisted read marker) and closes it (closes #611).

Bug fixes

  • Sidebar click alignment – clicking a conversation below a hidden (stale or archived) entry no longer selects the wrong chat; clicks now map to the exact rendered list.

Docs

  • The docsite caught up with reality: seven missing changelog entries, refreshed roadmap, keybinding command actions documented, database schema through v15, and a rewritten module reference.

v1.10.0

The automation-and-hardening release: a non-interactive CLI for scripting, supervised signal-cli reconnect, auto-lock, custom theme files, and the completion of the deep-review sweep (#492-#504) that finished moving every key handler out of app.rs.

New features

  • Non-interactive CLI--version, --check (setup health report), --send (one-shot send), --list (conversations), and --receive (streaming JSON) run without the TUI for scripts and automation (closes #257). Companion docs cover scheduling messages via the OS scheduler (closes #259), and the repo ships a Claude Code skill for the CLI (closes #258).
  • Supervised signal-cli reconnect – if the signal-cli child exits, siggy now emits an explicit Disconnected state, fails in-flight sends instead of leaving them stuck in Sending, and respawns the process with backoff (closes #497).
  • Auto-lock idle timer – new lock_timeout config locks the session after N minutes of keyboard inactivity, rounding out the v1.8.0 session lock (closes #438).
  • Custom theme template – drop a *.toml in the themes directory and it appears in /theme; the repo ships a fully-commented themes/custom-theme-template.toml starting point (closes #476).
  • Bindable command actionsopen_contacts, open_settings, open_help, toggle_sidebar, and attach are exposed as keybinding actions (unbound by default) so overlay opens can be driven from custom keys (closes #202).
  • Side-by-side accounts – new db_path config override lets a second config file point at its own message database (closes #260).
  • Clearer signal-cli failure hints – outdated or failing signal-cli now produces actionable errors, device-link failures surface signal-cli’s stderr, and a 409 “device limit / relink conflict” gets step-by-step recovery guidance.

Bug fixes

  • Control-character sanitization – exports, the debug log, and desktop notifications strip terminal escape sequences from remote-controlled text (message bodies, names) so a malicious message cannot inject escapes.
  • Startup spinner hang – the “Loading identities…” phase can no longer spin forever; a 20s watchdog surfaces the app with a warning instead. The splash-screen composer hint no longer suggests messaging before a conversation is open.
  • CLI lock contention--send/--receive report “another instance is using this account” instead of timing out when the TUI is running.
  • Black-square image diagnosis – transparency mapping in the halfblock renderer is pinned by tests and --debug logs each image’s transparency ratio to make the favicon black-square report (#443) diagnosable.

Performance

  • Message-pane line heights cached across frames, per-tick image viewport scans gated behind a signature, image encode caches bounded, off-window image_lines evicted, message search debounced, and composer input history capped (#490, #492).

Refactors and internal

  • Every remaining overlay, mode, and global key handler moved from app.rs into handlers/keys.rs, and the inline test module split into app_tests.rs (#494). domain/ made a leaf layer (#495). The ui::draw() mutation contract documented (#496).
  • New test coverage on the setup-wizard seams, the client stdout reader, DB migration upgrade paths on populated databases, scrolled-state render snapshots, and two new fuzz targets (cursor helpers, theme TOML) (#503).

v1.9.1

Patch release: install.sh now locates the signal-cli binary in the native tarball root (fixes fresh installs), and the release workflow supports manual dispatch with a tag input.


v1.9.0

A correctness-and-integrity release driven by a structured review sweep, plus README translations.

Bug fixes

  • Send-status integrity – failed sends can no longer display as sent; status transitions are guarded to be monotonic.
  • Author verification – incoming edits and remote-deletes are applied only when the sender is the original author.
  • Receipt and read-marker integrity – a cluster of fixes around receipt matching and read markers.
  • Poll routing and buffering – poll events route to the correct conversation and buffer correctly during sync.
  • Scrollback pagination – stopped a pagination runaway and made older history reachable again.
  • Message ordering – messages re-sort after the server-timestamp rewrite on send confirmation.
  • Attachment-open vettingo (open attachment) validates the target path before shell-opening it.
  • Theme parsing – multibyte hex colors in theme files are rejected instead of panicking.
  • Composer state – edit/reply targets clear when switching conversations.
  • Expiry sweep – positional indices shift correctly when disappearing messages are removed.

Performance

  • SQLite synchronous=NORMAL plus batched drain-loop transactions eliminate per-message fsyncs during sync bursts.

Docs

  • README translated into 11 languages under translations/.

v1.8.0

A maintenance-and-cleanup release: one big new feature (session lock + boss key), one notable contributor PR (native inline images inside tmux), substantial idle-CPU and image-decode fixes, plus a deep tech-debt sweep across the codebase.

New features

  • Session lock + boss keyCtrl-L or /lock blanks the chat behind an argon2-hashed passphrase prompt. /lock-reset changes the passphrase from inside the app (requires the current one). A new siggy --reset-lock CLI flag deletes the stored hash for forgotten-passphrase recovery (closes #261, #442).
  • Native inline images inside tmux – Kitty and iTerm2 image escapes are now wrapped in tmux’s DCS passthrough envelope so attachments render as actual pixels rather than halfblock approximations. New SIGGY_IMAGE_PROTOCOL env override (kitty / iterm2 / sixel / halfblock) covers the case where tmux hides the outer terminal from auto-detection. Requires tmux 3.3+ with set -g allow-passthrough on (closes #466). Thanks to @cultlead3r for this contribution.
  • Conversation delete – new /delete command removes the current conversation locally and declines pending message requests so the sender stops appearing (closes #312).

Bug fixes

  • Idle CPU pinning – skip per-tick housekeeping on bare mouse motion, cache the SetTitle payload, and drop unrecognised events without going around the loop. Resolves the “100% core idle” report on Alacritty + qtile
    • X11 (closes #408).
  • Image-decode hang – defensive 8192-pixel cap before decode (via image::Limits) plus start/done diagnostics so pathological link-preview images can no longer pin a background thread (closes #408).
  • send_rpc ordering – register the pending RPC entry after the stdin write succeeds, not before. Eliminates a 60s-TTL leak when serialize or channel-send fails (REL-001), and recovers from a poisoned mutex instead of silently dropping the registration (REL-002). Closes #434.
  • Welcome spinner freeze – the spinner counter now advances on the 80 ms wall-clock cadence whenever the app is loading, regardless of sync state. Previously paused entirely during the initial sync burst, which looked like a hung process on slow hardware (closes #426).
  • rand 0.8.6 – patches GHSA-cq8v-f236-94qc (build-time only impact for siggy, but bumping anyway to clear the advisory).

Diagnostics

  • Slow-decode warningrender_image now writes a WARN: line to the debug log file when any single decode exceeds 5000 ms, regardless of whether --debug was passed. Future “my CPU is pinned” reports land with the offending file path already on disk (closes #444).
  • Loop-rate digest – under --debug, the event loop emits a 5-second loop 5s: iter=N render=N term_ev=N sig_ev=N bg_in_flight=N summary that pinpoints whether CPU is going to terminal events, signal events, redraws, or background image decodes (#408 / #414).
  • Wire-format snapshot tests – the 5 special-shape send_* RPCs (bare recipient vs. wrapped array) now have JSON-shape regression tests so a future “tidy-up” refactor can’t silently break sends (closes #433).

Refactors

The codebase landed a substantial structural cleanup. None of these change behaviour for users; they make future feature work cheaper.

  • handlers/ extractionhandle_signal_event and friends extracted out of app.rs into handlers/signal.rs, handlers/input.rs, handlers/keys.rs (closes #399, #415, #417, #454).
  • signal/ splitsignal/client.rs split along the parser seam, 26 send_* methods deduped via a shared send_rpc helper, and signal/parse.rs (2758 LOC) split into a 5-file submodule (closes #400, #435, #455).
  • Data-driven tables – db schema migrations and the three keybinding profiles converted from imperative builders to static tables (closes #402 items B and C).
  • App field-count ratchet – new SettingsOverlayState consolidates the last loose overlay fields; baseline dropped 67 -> 65 (#352).
  • Typed enums replace stringly-typed fieldsnotification_preview and image_mode config fields, the "you" sender sentinel, and the action-menu key-hint are now compiler-enforced enums (closes #401, #453, #458).
  • SignalMessage::default – collapses 14 test fixtures from ~12 lines each to one or two non-default fields plus ..Default::default().
  • action_menu key handlers, list-overlay nav – shared helpers replace the copy-pasted Up/Down/FilterPush/FilterPop arms across 5 overlays, also fixing a latent empty-list underflow risk (closes #456).
  • Wire-quote dedup – quotes persisted only on the body row, not also on attachment rows (closes #423).
  • push_resolved decomposition – 185-line god function split into a thin orchestrator + 6 focused helpers (closes #454).

Internal

  • +25 tests since v1.7.1 (576 total -> 579 with #466). New coverage on send_rpc ordering edge cases, wire-format snapshots, 12 previously- untested SignalEvent dispatch paths, list-overlay nav, and the lock state machine.
  • Module docstrings backfilled across src/domain/ (closes #361).
  • Dependency bumps – tokio, tempfile, emojis, open, notify-rust, plus the rand security bump above.

Acknowledgements

Thanks to:

  • @cultlead3r for #466 (native inline images inside tmux). First-time contributor and a meaty PR.
  • @pcrockett for the detailed CPU-pinning report, debug logs, and confirmation testing on #408. The diagnostics this drove (loop-rate digest + slow-decode warning) will pay off long after the original incident.
  • @shwoop for the passphrase-recovery review feedback on #437 that led directly to the --reset-lock flag.

v1.7.1

Patch release: the setup wizard verifies the signal-cli path is actually spawnable before advancing, instead of failing later with a confusing error.


v1.7.0

New features

  • Settings categories – the settings overlay groups toggles under section headers instead of one flat list.
  • Newline rendering – multi-line message bodies render their line breaks (closes #318), and Shift+Enter newline input is documented (closes #334).

Bug fixes

  • UUID-only contacts – messages route correctly to contacts that have no phone number visible (closes #315).
  • @mention re-resolution – mentions re-resolve when the contact/group list arrives after the message (closes #283).
  • Viewport stability – the message viewport no longer jumps around during the initial sync burst.
  • Native image placement – aligned with Paragraph’s actual word wrap, fixing images drawn at the wrong row.

Performance

  • PNG pre-encoding moved to a background thread for all native image protocols.

Internal

  • ConversationStore extracted from the App god object – thanks to @Dowsley for this contribution.
  • Rust toolchain pinned (1.95.0) and cargo fmt enforced in CI.

v1.6.0

New features

  • Sixel image protocol – native inline images on Windows Terminal and other Sixel-capable terminals (closes #272).
  • Open attachments and URLs from the action menuo on a focused message opens the attachment or link (closes #188).
  • Multiple concurrent typing indicators – group chats show every member currently typing, not just one (closes #218).
  • Enhanced emoji support – broader shortcode coverage – thanks to @shwoop.
  • Sidebar width persistence – the sidebar width setting survives restarts.
  • Stale conversation filtering – empty groups and unresolvable contacts are hidden from the default sidebar view.

Bug fixes

  • Wayland clipboard – enabled the data-control backend so copy works on Wayland without a focused window – thanks to @clinta.
  • Vim normal-mode keybindings – a cluster of navigation fixes (closes #288, #289, #290, #291).
  • Redundant redraws – mouse-move events no longer trigger full redraws (closes #281).
  • JVM spawn – eliminated a redundant signal-cli JVM spawn on startup.
  • Native image caches – preserved across conversation switches.

Internal

  • Began extracting domain state structs from the App god object; shared list-overlay helpers introduced.

v1.5.1

Patch release: fixed the signal-cli native archive naming in install.sh (fresh installs failed to download signal-cli), plus major dependency bumps (ratatui 0.30, crossterm 0.29, rusqlite 0.38, toml 1.0).


v1.5.0

New features

  • Sidebar filter – press s in Normal mode to type-to-filter conversations in the sidebar. Filter text shows in the sidebar title. Enter selects the first match, Esc cancels. Tab/Shift-Tab and mouse clicks clear the filter (#184)
  • Unread count in sidebar – conversations with unread messages now show the count as (N) next to the name, alongside the existing unread dot (#185)
  • Jump to quoted message – press Q in Normal mode on a message with a quote to jump to the original message. Press Ctrl+O to jump back. Supports a stack for multiple jumps (#187)
  • Export chat history/export dumps the active conversation to a plain text file in your Downloads directory. /export 100 exports only the last 100 messages. Output includes timestamps, senders, edited labels, and quoted replies (#191)
  • Hide reactions – new “Show reactions” toggle in /settings (default: on). When disabled, reaction summary lines are hidden from messages (#211)
  • Emoji-to-text mode – new “Emoji to text” toggle in /settings (default: off). Converts emoji to text emoticons (:) <3 +1) or :shortcodes: at render time. Display-only – stored messages keep original emoji (#213)

Bug fixes

  • Reaction undo – selecting the same emoji you already reacted with now removes the reaction instead of being a no-op, matching other Signal clients (#216)

Internal

  • Integration snapshot tests – 16 insta-based snapshot tests render the full UI via ratatui’s TestBackend and compare against committed .snap files. Covers sidebar layout, chat messages, overlays, message features, and edge cases (#230)
  • Search dedup – extracted SearchState::open() method and dispatch_search_action() to remove duplicated match arms (#238)
  • Typing state extraction – moved typing indicator state and logic from App into domain::TypingState (#239)
  • Snapshot timezone fix – demo timestamps built via Local timezone so snapshots display stable hours across timezones (#238)

Thanks to @Dowsley for #238 and #239, and @shwoop for reporting #216.


v1.4.2

Bug fixes

  • Paste attachment deleted before send - /paste images were deleted immediately after dispatching the RPC, before signal-cli could read the file. Now defers cleanup until signal-cli confirms or fails the send (#229)

Internal

  • Unified event loop - consolidated run_app and run_demo_app into a single function using a MessagingBackend enum. Demo mode now inherits all main loop features (#214)
  • File picker extraction - moved file picker state and logic out of the App god object into domain::FilePickerState, the first step toward #221 (#226)
  • Dead code cleanup - marked find_settings_profile as test-only with #[cfg(test)] (#225)

Infrastructure

  • Homebrew tap - brew tap johnsideserf/siggy && brew install siggy now works on macOS. Formula auto-updates on each release (#227)
  • Mermaid diagrams - added interactive architecture, data flow, and module dependency diagrams to the developer guide

Thanks to @Dowsley for #214 and #226, @shwoop for #227 and #229, and @zamadye for #225.


v1.4.1

New features

  • Clipboard paste/paste (alias /pa) pastes clipboard contents directly into a conversation. Images (e.g. screenshots) are saved as a temp PNG and staged as an attachment with inline preview. Text pastes into the input buffer (#217)

Bug fixes

  • Typing indicator bleed – group typing events were keyed by sender phone number instead of group ID, causing indicators to appear in unrelated conversations. Now correctly scoped to the conversation they belong to (#215)

Enhancements

  • Outgoing image preview – sending an image attachment now renders an inline preview in the chat area immediately, matching the behavior of received images (#217)

Thanks to @shwoop for both changes in this release.


v1.4.0

New features

  • Settings profiles – predefined profiles (Default, Compact, Minimal) that apply a set of display settings at once. Select from /settings > Profile (#210)
  • Date separators – day-boundary separator lines between messages showing “Today”, “Yesterday”, or full date. Toggle via /settings (#210)
  • Background image – set a background image for the chat area via background_image in config.toml. Renders as dimmed halfblock art behind messages (#210)
  • Startup spinner – shows a spinner with status messages (syncing, loading contacts/groups/identities) during startup instead of a blank screen (#196)
  • Quit confirmation – prompts before quitting if there’s unsent text in the input buffer (#203)

Bug fixes

  • Disappearing message icon clipping – the timer icon was overlapping the timestamp on narrow windows. Now properly spaced (#201)
  • iTerm2 image squishing – images squished vertically when the chat was partially scrolled. Fixed pixel-level offset calculation (#198)

Enhancements

  • Kitty Unicode Placeholders – native image rendering now uses Kitty’s Unicode placeholder method for flicker-free, properly composited images (#195)
  • Settings hints – settings menu items now show a brief description of what each toggle does (#193)
  • Sidebar reorder on activity – the sidebar now reorders conversations to the top when messages are sent or received, keeping active chats visible (#205, #212)

Thanks to @Dowsley for #195, #198, and #205.


v1.3.3

Bug fixes

  • Kitty image ghosting – images that scrolled out of view persisted on screen. Now deletes all Kitty placements before each redraw (#180)
  • Kitty image stretching – partially visible images were scaled into fewer rows instead of being cropped. Now uses Kitty source-rect params to crop at the pixel level (#180)
  • Kitty image flickering – images were deleted and re-transmitted every frame even when unchanged. Now tracks the previous frame’s visible images and skips redundant redraws (#180)
  • Native image cache consistency – the first visible height would win in the cache, producing wrong results at different sizes. Images are now always encoded at full dimensions and cropped at display time (#180)

Enhancements

  • Synchronized updates – the entire render cycle (clear + text draw + image overlay) is now wrapped in a synchronized update, eliminating flash on conversation switch and during scroll (#180)
  • Suppressed Kitty responses – added q=2 to Kitty graphics commands to prevent response bytes from leaking into stdin (#180)

Thanks to @Dowsley for this release.


v1.3.2

Bug fixes

  • Cursor blink fix – the event loop was redrawing every 50ms regardless of state changes, resetting the terminal’s cursor blink timer. Now frames are only drawn when state actually changes (#177)

Enhancements

  • Proxy config – added a proxy field to config.toml for Signal TLS proxy URLs, passed through to signal-cli as --proxy. Useful for connecting in censored regions (#178)

Developer

  • Fuzz testing – added cargo-fuzz harnesses for JSON-RPC parsing, UTF-8 cursor operations, keybinding parsing, and slash command parsing (#179)

v1.3.1

Bug fixes

  • Image attachments – fixed image attachments not rendering after restart on Unix (#175)
  • Quit aliases – added :q and :quit as aliases for /quit (#174)

v1.3.0

Bug fixes

  • Unicode input crash – typing accented characters (á, ã, ó, é) caused a panic due to cursor arithmetic assuming single-byte characters. All cursor movement, insertion, and deletion now correctly handle multi-byte UTF-8 characters (#166)

Security

  • SQLite secure_delete – added PRAGMA secure_delete = ON so deleted message content is zeroed in database free pages rather than left recoverable (#161)
  • Debug log PII redaction--debug now masks phone numbers and message bodies in log output. Use --debug-full for unredacted output when needed (#163)
  • Security documentation – added a comprehensive security page to the docsite covering trust model, credential storage, encryption at rest, and privacy features (#164)

Enhancements

  • Notification preview in settings – the notification_preview setting (“full”, “sender”, “minimal”) is now accessible as a cycle toggle in the /settings overlay (#162)

v1.2.2

Bug fixes

  • Uppercase keybindings broken – Shift+J/K (focus next/prev message), G (scroll to bottom), N (prev search), Y (copy all), and other uppercase bindings stopped working after the keybindings refactor. crossterm sends uppercase chars with a SHIFT modifier that the lookup didn’t account for
  • j/k scroll snapping back – pressing j/k to scroll would briefly shift the viewport then immediately snap back to the bottom. The draw code was deriving a focus index that triggered a “keep focused message visible” adjustment on the next frame

v1.2.1

Bug fixes

  • Autocomplete popup crash – fixed a panic when opening the autocomplete popup (/ commands, @ mentions) in a small terminal window. The popup now clamps to available space and skips rendering if the terminal is too small

v1.2.0

New features

  • Configurable keybindings – keybindings are now fully configurable via three built-in profiles (Default, Emacs, Minimal), custom TOML profiles in ~/.config/siggy/keybindings/, and per-key overrides in ~/.config/siggy/keybindings.toml. Switch profiles and rebind keys live in the new /keybindings overlay. The help overlay dynamically reflects active bindings (#138)
  • Message history pagination – scrolling to the top of a conversation automatically loads older messages from the database, with a loading indicator while fetching (#158)
  • Multi-line message input – press Alt+Enter (or Shift+Enter) to insert newlines in your message before sending (#157)

v1.1.0

New features

  • Forward messages – press f in Normal mode to forward a message to another conversation via a filterable picker overlay (#139)
  • Scroll position memory – switching between conversations now remembers and restores your scroll position (#137)
  • Notification preview levels – new notification_preview config option with three levels: "full" (default), "sender", or "minimal" (#132)
  • Clipboard auto-clear – clipboard is automatically cleared 30 seconds after copying a message. Configurable via clipboard_clear_seconds (#131)
  • Tree-style connectors – quotes and link previews now use curved Unicode box-drawing characters (, , ) for visual clarity (#141)

Security

  • Identity verification fix/verify now uses verifiedSafetyNumber instead of trustAllKnownKeys, requiring explicit safety number confirmation before trusting a contact’s identity key (#144)
  • Secure debug log – debug log moved from CWD to ~/.cache/siggy/debug.log with 10MB rotation and a startup warning (#134)
  • Incognito attachment isolation--incognito mode now redirects attachment downloads to a temp directory that is auto-deleted on exit (#133)

v1.0.1

Security

  • OSC 8 escape injection fix – URLs in messages are now sanitized before being embedded in terminal hyperlink escape sequences, preventing crafted URLs from manipulating terminal state (title, colors, screen)
  • Attachment path traversal fix – attachment filenames from signal-cli are now sanitized by replacing path separators and .. traversal sequences, preventing writes outside the configured download directory

v1.0.0

Rename to siggy

  • Renamed from signal-tui to siggy – the binary, package, config paths, data paths, and database filename are all now “siggy” (#127)
  • Automatic migration – existing config directories, data directories, and database files are seamlessly migrated from the old “signal-tui” paths on first launch. No manual action required
  • Published to crates.io – install with cargo install siggy (closes #11)

Docsite

  • Brand theme – docsite color palette updated from gray mIRC to siggy’s navy-blue brand colors in both light and dark modes (#128)
  • Logo integration – siggy logo and favicon added to the docsite intro page and menu bar

Repo hygiene

  • Cargo.lock tracked – binary crate now correctly tracks its lockfile
  • .gitignore cleanup – added IDE directories and platform artifacts

v0.9.0

Pinned messages

  • Pin and unpin messages – press p in Normal mode or use the action menu to pin a message. Choose a pin duration (forever, 24h, 7d, 30d). Pinned messages show a banner at the top of the chat area. Unpin by pressing p again. Pin state syncs across devices (closes #65)
  • URL preview display – messages containing URLs now show link preview cards with title, description, and thumbnail image (when available). Toggle via /settings > “Link previews” (closes #63)

Polls

  • Create polls – use /poll "question" "opt1" "opt2" to create a poll. Add --single to restrict to single-select. Polls display as inline bar charts showing vote counts and percentages
  • Vote in polls – press Enter on a poll message to open the vote overlay. Select options with Space, confirm with Enter. Multi-select polls allow toggling multiple options (closes #64)

Identity verification

  • /verify command – verify the identity keys of your contacts. In 1:1 chats, shows the safety number and trust level. In groups, browse members and verify individually. Trust/untrust identity keys directly from the overlay (closes #70)

Profile editor

  • /profile command – edit your Signal profile directly from the TUI. Change your given name, family name, about text, and about emoji. Navigate with j/k, Enter to edit fields inline, and Save to push changes via updateProfile RPC (closes #69)

About overlay

  • /about command – shows app version, description, author, license, and repository link. Press any key to close
  • Left/right sidebar – new setting to place the sidebar on the right side instead of the default left. Toggle via /settings > “Sidebar on right” (closes #125)

Bug fixes

  • Mouse selection – fixed mouse click positioning in the input bar, right-click paste, and slow Ctrl+V behavior (#124)
  • Poll vote counting – votes now correctly use vote_count as a multiplier instead of always counting as 1 (#122)
  • Mention parsing – fixed mention field names to match signal-cli’s actual protocol (#108)

Internal

  • Test coverage – added unit tests for UI helpers and event handlers, migrated to rstest parameterized tests (#109, #113, #120)
  • Robustness – removed unsafe unwraps, surfaced DB errors in status bar, used binary search for message insertion (#118, #119)

v0.8.0

Disappearing messages

  • Timer support – siggy now honors disappearing message timers. Messages auto-expire after the configured duration, with a countdown shown in the chat area. Set the timer with /disappearing <duration> (alias /dm) using values like 30s, 5m, 1h, 1d, 1w, or off (closes #61)

Group management

  • /group command – manage groups directly from the TUI (alias /g). Opens a menu with options to view members, add/remove members, rename the group, create a new group, or leave a group. Add/remove members use a type-to-filter contact picker (closes #26)

Message requests

  • Unknown sender detection – messages from unknown senders (not in your contacts) are now flagged as message requests. A banner appears with options to accept (start chatting) or delete the conversation. Unaccepted conversations do not trigger notifications or send read receipts (closes #62)

Block and unblock

  • /block and /unblock commands – block or unblock the current conversation’s contact or group. Blocked conversations do not trigger notifications, read receipts, or typing indicators (closes #60)

Mouse support

  • Clickable sidebar – click conversations in the sidebar to switch
  • Scrollable messages – scroll wheel in the chat area
  • Overlay navigation – scroll wheel navigates lists in overlays
  • Click to position cursor – click in the input bar to place the cursor
  • Configurable via /settings > “Mouse support” (default: on) (closes #17)

Color themes

  • Selectable themes – open the theme picker with /theme (alias /t) or from /settings > Theme. Includes built-in themes with customizable sidebar, chat, status bar, and accent colors (closes #18)

Desktop notifications

  • OS-level notifications – cross-platform desktop notifications using notify-rust (Linux D-Bus, macOS NSNotification, Windows WinRT toast). Shows sender name and message preview. Toggle via /settings > “Desktop notifications” (default: off) (closes #19)

Bug fixes

  • Mouse capture on Windows – mouse support no longer breaks after signal-cli starts on Windows. Spawning signal-cli.bat (cmd.exe) was resetting console input mode flags (#105)

Database

  • Migration v7 – adds expiration_timer to conversations and expires_in_seconds, expiration_start_ms to messages (disappearing messages)
  • Migration v8 – adds accepted column to conversations (message requests)
  • Migration v9 – adds blocked column to conversations (block/unblock)

v0.7.0

Text styling

  • Rich text rendering – messages with Signal formatting now display with proper styling: bold, italic, strikethrough, monospace, and spoiler text. Spoiler content is hidden behind block characters (closes #66)

Sticker messages

  • Sticker display – incoming stickers are now shown as [Sticker: emoji] in the chat area instead of being silently dropped (closes #67)

View-once messages

  • View-once handling – view-once messages display as [View-once message] with attachments suppressed, respecting the ephemeral intent (closes #68)

Cross-device read sync

  • Read state sync – when you read messages on your phone or another linked device, siggy marks those conversations as read and updates unread counts automatically (closes #71)

System messages

  • Missed calls – missed voice and video calls now show as system messages
  • Safety number changes – a warning appears when a contact’s safety number changes
  • Group updates – group metadata changes (member adds/removes) display as system messages
  • Disappearing message timer – changes to the expiration timer show a human-readable message (e.g. “Disappearing messages set to 1 day”)

Message action menu

  • Enter key menu – press Enter in Normal mode on a focused message to open a contextual action menu. Available actions (shown with key hints): Reply (q), Edit (e), React (r), Copy (y), Delete (d). Navigate with j/k, press Enter to execute, or use the shortcut key directly (closes #85)

Bug fixes

  • “New messages” bar – the unread separator no longer persists after viewing a conversation with new messages (#90)

v0.6.1

Bug fixes

  • j/k scroll fixed – viewport no longer gets stuck when scrolling with j/k. The root cause was the message window expanding in lockstep with scroll offset, keeping the viewport position constant (#84)
  • J/K navigation in short conversationsJ/K message jumping now works even when all messages fit the viewport (no scroll offset needed) (#84)
  • Edit preserves quotes – editing a quoted message no longer strips the original quote on remote clients. The wire-format phone number is now preserved through display name resolution (#84)
  • Contact names no longer revert to phone numbers – conversations would permanently show phone numbers in the sidebar when messages arrived before the contact list synced. Fixed by preventing phone-number fallback names from overwriting real display names in the database (#84)
  • Contact name recovery on startup – 1:1 conversations still named as phone numbers (e.g. when signal-cli’s contact list has no cached profile name) now recover the correct name from stored message sender fields (#86)
  • Reaction sender names after reload – reaction senders no longer revert to phone numbers after restarting the app (#80)
  • Non-contact name resolution – display names for non-contacts in reactions and quotes are now resolved correctly (#83)
  • Mention placeholders in quotes – U+FFFC placeholder characters from @mentions are now stripped from quoted text (#79)

Improvements

  • Loading screen – a loading indicator now appears during startup while contacts and groups sync from signal-cli (#81, #82)
  • Install scripts updated – Windows and macOS install scripts now reference Java 25+ (required by signal-cli 0.14). The Windows script checks the actual Java version before installing signal-cli (#87)

v0.6.0

Reply, edit, and delete messages

  • Quote reply – press q in Normal mode on any message to reply with a quote. A reply indicator appears above the input box, and the sent message includes a quoted block showing the original author and text (closes #15)
  • Edit messages – press e on your own outgoing message to edit it. The original text is loaded into the input buffer for modification. Edited messages display an “(edited)” label. Edits sync across devices (closes #24)
  • Delete messages – press d on any message to open a delete confirmation. Outgoing messages offer “delete for everyone” (remote delete) or “delete locally”. Incoming messages can be deleted locally. Deleted messages show as “[deleted]” (closes #23)
  • /search command – search across all conversations with /search <query> (alias: /s). Results appear in a scrollable overlay showing sender, message snippet, and conversation name. Press Enter to jump directly to the message in context. Use n/N in Normal mode to cycle through matches (closes #14)
  • Highlight matches – search terms are highlighted in the result snippets

File attachments

  • /attach command – send files with /attach to open a file browser overlay. Navigate with j/k, Enter to select, Backspace to go up a directory. The selected file attaches to your next message, shown as a pending indicator in the input area (closes #54)

/join autocomplete

  • Contact and group autocomplete/join now offers Tab-completable suggestions from your contacts and groups. Type /join and see matching names, or keep typing to filter. Groups and contacts are distinguished by color (closes #21)

Send typing indicators

  • Outbound typing – siggy now sends typing indicators to your conversation partner while you type. Typing state starts on the first keypress, auto-stops after 5 seconds of inactivity, and stops immediately when you send or switch conversations (closes #58)

Send read receipts

  • Read receipt sending – when you view a conversation, read receipts are automatically sent to message senders, letting them know you’ve read their messages. Controlled by the “Send read receipts” toggle in /settings (closes #59)

Welcome screen

  • Getting started hints – the welcome screen now shows useful commands and navigation tips including Tab/Shift+Tab for cycling conversations

Bug fixes

  • Out-of-order messages – messages with delayed delivery timestamps are now inserted in correct chronological order (#56)
  • Link highlight – fixed background color bleeding on highlighted links and J/K message navigation edge cases (#55)

Database

  • Migration v5 – adds index on messages(conversation_id, timestamp_ms) for faster search queries
  • Migration v6 – adds is_edited, is_deleted, quote_author, quote_body, quote_ts_ms, and sender_id columns to the messages table

v0.5.0

Message reactions

  • Emoji reactions – react to any message with r in Normal mode to open the reaction picker. Navigate with h/l or 1-8, press Enter to send. Reactions display below messages as compact emoji badges (e.g. 👍 2 ❤️ 1) with an optional verbose mode showing sender names (closes #16)
  • Reaction sync – incoming reactions, sync reactions from other devices, and reaction removals are all handled in real time
  • Persistence – reactions are stored in the database (migration v4) and restored on startup

@mentions

  • Mention autocomplete – type @ in group chats to open a member autocomplete popup. Filter by name, press Tab to insert the mention. Works in 1:1 chats too (with the conversation partner)
  • Mention display – incoming mentions are highlighted in cyan+bold in the chat area

Visible message selection

  • Focus highlight – when scrolling in Normal mode, the focused message gets a subtle dark background highlight so you can see exactly which message reactions and copy will target
  • J/K navigation – Shift+j and Shift+k jump between actual messages, skipping date separators and system messages

Startup error handling

  • stderr capture – signal-cli startup errors (missing Java, bad config, etc.) are now captured and displayed in a TUI error screen instead of silently failing

Internal

  • Major refactoring across four PRs (#45-#48): extracted shared key handlers, data-driven settings system, split parse_receive_event into sub-functions, modernized test helpers, added persistent debug log and pending_requests TTL

v0.4.0

Contact list

  • /contacts command – new overlay for browsing all synced contacts, with j/k navigation, type-to-filter by name or number, and Enter to open a conversation (alias: /c) (closes #22)

Clipboard

  • Copy to clipboard – in Normal mode, y copies the selected message body and Y copies the full formatted line ([HH:MM] <sender> body) to the system clipboard (closes #28)
  • Full timestamp on scroll – when scrolling through messages in Normal mode, the status bar now shows the full date and time of the focused message (e.g. “Sun Mar 01, 2026 12:34:56 PM”) (closes #27)

v0.3.3

Bug fixes

  • Settings persistence – changes made in /settings are now saved to the config file and persist between sessions (fixes #40)
  • Input box scrolling – long messages no longer disappear when typing past the edge of the input box; text now scrolls horizontally to keep the cursor visible (fixes #39)
  • Image preview refresh – toggling “Inline image previews” in /settings now immediately re-renders or clears previews on existing messages (fixes #41)

Settings

  • Tab to toggle – Tab key now toggles settings items in the /settings overlay, alongside Space and Enter

v0.3.2

Read receipts and delivery status

  • Message status indicators – outgoing messages now show delivery lifecycle symbols: Sending → Sent → Delivered → Read → Viewed
  • Real-time updates – status symbols update live as recipients receive and read your messages
  • Group receipt support – delivery and read receipts work correctly in group conversations
  • Race condition handling – receipts that arrive before the server confirms the send are buffered and replayed automatically
  • Persistent status – message status is stored in the database and restored on reload (stale “Sending” messages are promoted to “Sent”)
  • Nerd Font icons – optional Nerd Font glyphs available via /settings > “Nerd Font icons”
  • Configurable – three new settings toggles: “Show read receipts” (on/off), “Receipt colors” (colored/monochrome), “Nerd Font icons” (unicode/nerd)

Debug logging

  • --debug flag – opt-in protocol logging to siggy-debug.log for diagnosing signal-cli communication issues

Database

  • Migration v3 – adds status and timestamp_ms columns to the messages table (automatic on first run)

v0.3.1

Image attachments

  • Embedded file links – attachment URIs are now hidden behind clickable bracket text (e.g. [image: photo.jpg]) instead of showing the raw file:/// path
  • Double extension fix – filenames like photo.jpg.jpg are stripped to photo.jpg when signal-cli duplicates the extension
  • Improved halfblock previews – increased height cap from 20 to 30 cell-rows for better inline image quality
  • Native image protocols – experimental support for Kitty and iTerm2 inline image rendering, off by default. Enable via /settings > “Native images (experimental)”
  • Pre-resized encoding – native protocol images are resized and cached as PNG before sending to the terminal, avoiding multi-megabyte raw file transfers every frame

Attachment lookup

  • MSYS/WSL path fixfind_signal_cli_attachment now checks both platform-native data dirs (AppData/Roaming) and POSIX-style (~/.local/share) where signal-cli stores files under MSYS or WSL. Fixes outgoing images sent from Signal desktop not displaying in the TUI.

Platform

  • Windows Ctrl+C fix – suppress the STATUS_CONTROL_C_EXIT error on exit by disabling the default Windows console handler (crossterm already captures Ctrl+C as a key event in raw mode)

Documentation

  • mdBook documentation site with custom mIRC/Win95 light theme and dark mode toggle

v0.3.0

Initial public release.

  • Terminal Signal client wrapping signal-cli via JSON-RPC
  • Vim-style modal input (Normal/Insert modes)
  • Sidebar with conversation list, unread counts, typing indicators
  • Inline halfblock image previews
  • OSC 8 clickable hyperlinks
  • SQLite persistence with WAL mode
  • Incognito mode (--incognito)
  • Demo mode (--demo)
  • First-run setup wizard with QR device linking
  • Slash commands: /join, /part, /quit, /sidebar, /help, /settings, /mute, /notify, /bell
  • Input history (Up/Down recall)
  • Autocomplete popup for commands and @mentions
  • Configurable notifications (direct/group) with terminal bell
  • Cross-platform: Linux, macOS, Windows

Installation

Homebrew (macOS)

brew tap johnsideserf/siggy
brew install siggy

Pre-built binaries for Apple Silicon and Intel Macs. The tap is automatically updated on each release.

From crates.io

Requires Rust 1.70+.

cargo install siggy

Pre-built binaries

Download the latest release for your platform from the Releases page.

Linux / macOS (one-liner)

curl -fsSL https://raw.githubusercontent.com/johnsideserf/siggy/master/install.sh | bash

Windows (PowerShell)

irm https://raw.githubusercontent.com/johnsideserf/siggy/master/install.ps1 | iex

Both install scripts download the latest release binary and check for signal-cli.

Build from source

Requires Rust 1.70+.

Install directly from the repository:

cargo install --git https://github.com/johnsideserf/siggy.git

Or clone and build locally:

git clone https://github.com/johnsideserf/siggy.git
cd siggy
cargo build --release
# Binary is at target/release/siggy

signal-cli setup

siggy requires signal-cli as its messaging backend.

  1. Install signal-cli – follow the signal-cli installation guide. The install scripts above will check for it automatically.

  2. Make it accessible – signal-cli must be on your PATH, or you can set the full path in the config file:

    signal_cli_path = "/usr/local/bin/signal-cli"
    

    On Windows, point to signal-cli.bat if it isn’t in your PATH.

  3. Java runtime – signal-cli 0.14+ requires Java 25+. Make sure java is available in your shell. On Linux, the install script uses the native signal-cli build which does not require Java.

Supported platforms

PlatformBinaryNotes
Linux x86_64siggy-vX.Y.Z-x86_64-unknown-linux-gnu.tar.gz
macOS x86_64siggy-vX.Y.Z-x86_64-apple-darwin.tar.gzIntel Macs
macOS arm64siggy-vX.Y.Z-aarch64-apple-darwin.tar.gzApple Silicon
Windows x86_64siggy-vX.Y.Z-x86_64-pc-windows-msvc.zip

Getting Started

First launch

Run siggy with no arguments:

siggy

If no config file exists, the setup wizard starts automatically.

Setup wizard

The wizard walks through three steps:

  1. Locate signal-cli – siggy searches your PATH for signal-cli. If it can’t find it, you’ll be prompted to enter the full path.

  2. Enter your phone number – provide your Signal phone number in E.164 format (e.g. +15551234567). This is the account siggy will connect to.

  3. Link your device – a QR code is displayed in the terminal. Scan it with the Signal app on your phone:

    • Open Signal on your phone
    • Go to Settings > Linked Devices > Link New Device
    • Scan the QR code shown in the terminal

Once linked, siggy saves your config and starts the main interface.

Re-running setup

To re-run the setup wizard at any time:

siggy --setup

This is useful if you need to link a different account or reconfigure signal-cli.

Demo mode

Try the full UI without a Signal account or signal-cli:

siggy --demo

Demo mode populates the interface with dummy conversations and messages. It’s useful for exploring keybindings, commands, and the layout before committing to setup.

CLI options

FlagDescription
-a, --account <NUMBER>Phone number in E.164 format (overrides config)
-c, --config <PATH>Path to a custom config file
--setupRe-run the first-time setup wizard
--demoLaunch with dummy data (no signal-cli needed)
--incognitoIn-memory storage only; nothing persists after exit

Basic navigation

Once launched, the interface has three areas:

  • Sidebar (left) – lists your conversations; groups are prefixed with #
  • Chat area (center) – shows messages for the selected conversation
  • Input bar (bottom) – type messages and commands here

Use Tab / Shift+Tab to switch between conversations, or type /join <name> to jump to a specific contact or group.

Press Esc to enter Normal mode for vim-style scrolling and navigation. The default mode is Insert, where you can type messages immediately.

Configuration

Config file location

siggy loads its config from a TOML file at the platform-specific path:

PlatformPath
Linux / macOS~/.config/siggy/config.toml
Windows%APPDATA%\siggy\config.toml

You can override the path with the -c flag:

siggy -c /path/to/config.toml

Config fields

All fields are optional. Here is a complete example with defaults:

account = "+15551234567"
signal_cli_path = "signal-cli"
download_dir = "/home/user/signal-downloads"
notify_direct = true
notify_group = true
desktop_notifications = false
notification_preview = "full"
clipboard_clear_seconds = 30
lock_timeout = 0
image_mode = "halfblock"
image_max_width = 40
preview_image_max_width = 30
image_max_height = 30
sixel_max_colors = 256
sixel_diffusion = 0.875
show_link_previews = true
date_separators = true
show_receipts = true
color_receipts = true
nerd_fonts = false
emoji_to_text = false
show_reactions = true
reaction_verbose = false
send_read_receipts = true
mouse_enabled = true
sidebar_on_right = false
theme = "Default"
keybinding_profile = "Default"
settings_profile = "Default"

Field reference

FieldTypeDefaultDescription
accountstring""Phone number in E.164 format
signal_cli_pathstring"signal-cli"Path to the signal-cli binary
download_dirstring~/signal-downloads/Directory for downloaded attachments
notify_directbooltrueTerminal bell on new direct messages
notify_groupbooltrueTerminal bell on new group messages
desktop_notificationsboolfalseOS-level desktop notifications for incoming messages
notification_previewstring"full"Notification content level: full, sender, or minimal
clipboard_clear_secondsint30Seconds before clipboard auto-clears after copying (0 = disabled)
lock_timeoutint0Minutes of keyboard inactivity before the session auto-locks (0 = disabled)
db_pathstring(unset)Override the message database path. Absolute paths are used as-is; relative paths resolve under the data dir. Leave unset for the default siggy.db. Used to run multiple accounts side by side (see below)
image_modestring"halfblock"Image rendering mode: native (Kitty / iTerm2 / Sixel), halfblock (universal Unicode fallback), or none
image_max_widthint40Maximum attachment image width in terminal cells
preview_image_max_widthint30Maximum link preview thumbnail width in terminal cells
image_max_heightint30Maximum image height in terminal cell rows
sixel_max_colorsint256Maximum Sixel palette colors (2-256)
sixel_diffusionfloat0.875Sixel Floyd-Steinberg diffusion strength (0.0-1.0)
audio_playerstring(unset)Command for inline voice message playback, e.g. "mpv --no-config". Leave unset to autodetect a player on PATH (mpv, ffplay, afplay, cvlc, paplay, aplay)
show_link_previewsbooltrueShow link preview cards for URLs in messages
date_separatorsbooltrueShow date separator lines between messages from different days
show_receiptsbooltrueShow delivery/read receipt status symbols
color_receiptsbooltrueColored receipt status symbols (vs monochrome)
nerd_fontsboolfalseUse Nerd Font glyphs for status symbols
emoji_to_textboolfalseConvert emoji to text emoticons/shortcodes
show_reactionsbooltrueShow emoji reactions on messages
reaction_verboseboolfalseShow reaction sender names instead of counts
send_read_receiptsbooltrueSend read receipts when viewing conversations
mouse_enabledbooltrueEnable mouse support (click sidebar, scroll, etc.)
sidebar_on_rightboolfalseDisplay sidebar on the right side instead of left
themestring"Default"Color theme name
keybinding_profilestring"Default"Keybinding profile (Default, Emacs, Minimal, or custom)
settings_profilestring"Default"Settings profile preset (Default, Minimal, Full, or custom)

Removed: proxy. Earlier versions documented a proxy field that was passed to signal-cli as --proxy, a flag signal-cli does not have, so it never worked and connections were never proxied (#656). siggy now refuses to start while proxy is set; remove the line from config.toml. To route signal-cli through a proxy, use an OS-level mechanism such as JVM proxy properties (JAVA_TOOL_OPTIONS) or proxychains.

CLI flags

CLI flags override config file values for the current session:

FlagOverrides
-a +15551234567account
-c /path/to/config.tomlConfig file path
--incognitoUses in-memory database (no persistence)
--demoLaunch with dummy data (no signal-cli needed)
--setupRe-run the first-time setup wizard
--debugWrite debug log to ~/.cache/siggy/debug.log (PII redacted)
--debug-fullSame as --debug but without redaction
--reset-lockDelete the session-lock passphrase hash and exit
--reset-accountClear local signal-cli account data for the configured account and exit, for a clean relink (siggy --reset-account then siggy --setup). Only removes local data; a server-side conflict also needs old devices removed on your phone
--checkPrint a setup health report (config, account, signal-cli, download dir) and exit; exit code 0 when ready, 1 otherwise
--send <TO> <MSG>Send one message non-interactively and exit (TO = +E164 for a 1:1 or a group id); exit 0 on confirmed send, 1 on failure/timeout
--listPrint cached conversations as tab-separated rows (unread, type, name, id) and exit; reads the local DB, no signal-cli needed
--receiveStream incoming messages as tab-separated rows (timestamp_ms, sender, group-id, body) until signal-cli disconnects or interrupted (Ctrl-C)
--watchHeadless trigger mode: run triggers.toml rules over the incoming stream (auto-reply / run commands) without the TUI
-V, --versionPrint siggy <version> to stdout and exit

Environment variables

VarEffect
SIGGY_IMAGE_PROTOCOLForce image protocol selection (kitty / iterm2 / sixel / halfblock). Useful inside tmux where auto-detection cannot see the outer terminal.

Settings overlay

Press /settings inside the app to open the settings overlay. This provides toggles for runtime settings:

  • Notification toggles (direct / group / desktop)
  • Notification preview level (cycles full / sender / minimal)
  • Sidebar visibility / position
  • Image mode (cycles native / halfblock / none) and link previews
  • Date separators
  • Show read receipts / receipt colors / nerd font icons
  • Emoji-to-text mode
  • Show reactions / verbose reactions
  • Send read receipts
  • Mouse support
  • Theme selector
  • Keybinding profile selector
  • Settings profile selector

Changes made in the settings overlay are saved to the config file when you close the overlay, and persist across sessions.

Settings profiles

Settings profiles are presets that configure all display toggles at once. Three built-in profiles are included:

ProfileDescription
DefaultBalanced defaults for most users
MinimalIRC-style stripped back UI - disables images, previews, receipts, notifications, date separators
FullEverything enabled

In the settings overlay, use h/l on the Profile row to cycle through profiles, or press Enter to open the profile management overlay where you can:

  • Load a profile (Enter) - applies all toggles and previews the result
  • Save as (S) - save your current settings as a new custom profile
  • Save (s) - overwrite a custom profile with current settings
  • Delete (d) - remove a custom profile

Custom profiles are stored as TOML files in ~/.config/siggy/profiles/. Built-in profiles cannot be modified or deleted.

Incognito mode

siggy --incognito

Incognito mode replaces the on-disk SQLite database with an in-memory database. No messages, conversations, or read markers are saved. When you exit, all data is gone. The status bar shows a bold magenta incognito indicator.

Multiple accounts

siggy runs one account per process, but you can use several accounts side by side by giving each its own config file (with its own account) and its own database via db_path, then selecting the config with -c:

# ~/.config/siggy/personal.toml
account = "+15551110000"
db_path = "personal.db"        # resolves to <data dir>/siggy/personal.db
# ~/.config/siggy/work.toml
account = "+15552220000"
db_path = "work.db"
siggy -c ~/.config/siggy/personal.toml
siggy -c ~/.config/siggy/work.toml

Each account keeps a separate message history. db_path accepts an absolute path too. Without db_path the default siggy.db is used, so existing single-account setups are unaffected. The non-interactive --list honors the selected config’s db_path as well.

Commands

All commands start with /. Type / in Insert mode to open the autocomplete popup.

Command reference

CommandAliasArgumentsDescription
/join/j<name>Switch to a conversation by contact name, number, @username, or group
/part/pLeave current conversation
/deleteDelete current conversation (declines pending message requests)
/search/s<query>Search messages across all conversations
/attach/aOpen file browser to attach a file
/paste/paPaste from clipboard (text or image)
/export[txt|md|json] [n]Export chat history to a file
/preview[url]Fetch a link preview for your next message (no arg discards)
/archiveArchive / unarchive current conversation
/unreadMark current conversation unread and close it
/triggersReload message trigger rules from triggers.toml
/sidebar/sbToggle sidebar visibility
/bell/notify[type]Toggle notifications (direct, group, or both)
/muteMute/unmute current conversation
/blockBlock current contact or group
/unblockUnblock current contact or group
/disappearing/dm<duration>Set disappearing message timer (off, 30s, 5m, 1h, 1d, 1w)
/group/gOpen group management menu
/theme/tOpen theme picker
/keybindings/kbOpen keybindings overlay
/poll"q" "a" "b" [--single]Create a poll
/verify/vVerify contact identity keys
/profileEdit your Signal profile
/aboutShow app info (version, license, etc.)
/contacts/cBrowse synced contacts
/settingsOpen settings overlay
/lockLock the session
/lock-resetChange the lock passphrase (needs current passphrase)
/help/hShow help overlay
/quit/qExit siggy

Autocomplete

When you type /, a popup appears showing matching commands. As you continue typing, the list filters down. Use:

  • Up/Down arrows to navigate the list
  • Tab to complete the selected command
  • Esc to dismiss the popup

/join autocomplete

After typing /join , a second autocomplete popup shows matching contacts and groups. Filter by name or phone number. Groups are shown in green. Press Tab to complete the selection.

Examples

Join a conversation by name:

/join Alice

Join by phone number:

/j +15551234567

Toggle direct message notifications off:

/bell direct

Toggle all notifications:

/bell

Mute the current conversation:

/mute

Delete the current conversation:

/delete

Removes the conversation, its messages, and any cached state from siggy. Accepted conversations are deleted only locally; for pending message requests this also sends Signal’s request-delete response so the sender stops appearing.

Search for a message:

/search hello

Attach a file:

/attach

This opens a file browser. Navigate with j/k, Enter to select a file or enter a directory, Backspace to go up. The selected file attaches to your next message.

Paste a screenshot from clipboard:

/paste

If the clipboard contains an image (e.g. a screenshot), it’s saved as a temp PNG and staged as an attachment. If it contains text, the text is inserted into the input buffer.

Block the current conversation:

/block

Set disappearing messages to 1 day:

/disappearing 1d

Disable disappearing messages:

/dm off

Open group management:

/group

This opens a menu with options to view members, add/remove members, rename the group, create a new group, or leave. Only available in group conversations (except create, which works anywhere).

Switch color theme:

/theme

Create a poll:

/poll "Lunch?" "Pizza" "Sushi" "Tacos"

Create a single-select poll:

/poll "Best editor?" "Vim" "Emacs" --single

Verify a contact’s identity:

/verify

Edit your Signal profile:

/profile

Navigate fields with j/k, press Enter to edit a field inline, Enter again to confirm (or Esc to cancel). Move to Save and press Enter to push changes.

Show app info:

/about

Export chat history:

/export

Exports all messages in the current conversation to a file in your Downloads directory (e.g. siggy-export-Alice-2026-03-14.txt). Pass a format for Markdown or JSON output:

/export md
/export json

Export last 50 messages (any format):

/export 50
/export md 50

Messaging a new contact

To start a conversation with someone not in your sidebar, use /join with their phone number in E.164 format:

/join +15551234567

The conversation will appear in your sidebar once the first message is exchanged.

Signal usernames

Contacts who use Signal’s phone-number privacy are reachable by username instead of number. /join accepts @ handles:

/join @alice.42

Known contacts resolve instantly; an unknown handle needs the full form with its numeric discriminator (name.123) and is looked up on the Signal servers. Usernames of your contacts also show next to their name in the chat header — toggle this off with the Show usernames setting (Ctrl+P → Settings).

The non-interactive sender accepts the same forms: siggy --send @alice.42 -m hi.

Keybindings

siggy uses vim-style modal editing with two modes: Insert (default) and Normal. All keybindings are configurable via profiles and per-key overrides.

Profiles

Three built-in profiles are available:

ProfileDescription
DefaultVim-style modal editing (Normal / Insert modes)
EmacsNo modal concept; Ctrl-based shortcuts in Insert mode
MinimalArrow-key centric; F-key shortcuts for actions

Set the profile in your config file:

keybinding_profile = "Default"

Or switch profiles live in the app via /keybindings or /settings > Keybindings.

Customizing keybindings

Per-key overrides

Create ~/.config/siggy/keybindings.toml to override individual keys on top of your active profile:

[global]
quit = "ctrl+q"

[normal]
scroll_up = "ctrl+j"
react = "ctrl+r"

[insert]
send_message = "ctrl+enter"

Custom profiles

Create full profiles in ~/.config/siggy/keybindings/myprofile.toml:

name = "My Custom"

[global]
quit = "ctrl+c"
next_conversation = "tab"

[normal]
scroll_up = "k"
scroll_down = "j"

[insert]
exit_insert = "esc"
send_message = "enter"
insert_newline = ["shift+enter", "alt+enter"]

Arrays are supported for binding multiple keys to the same action.

Command actions

Six commands are also exposed as bindable actions, so you can open overlays directly from a key instead of typing the slash command: open_contacts, open_settings, open_help, toggle_sidebar, attach, and command_palette. The palette is bound to Ctrl+P in the Default and Minimal profiles (unbound in Emacs, where Ctrl+P is line-up); the others are unbound by default so they never conflict with profile defaults. Bind them like any other action:

[global]
open_contacts = "ctrl+b"
open_help = "f1"

They also appear in the /keybindings overlay for in-app rebinding.

In-app rebinding

Open the keybindings overlay with /keybindings (alias /kb). Navigate actions with j/k, press Enter to capture a new key, Backspace to reset to profile default. Changes are saved automatically.

Default keybindings

The tables below show the Default profile bindings.

Global (both modes)

KeyAction
Ctrl+CQuit
Ctrl+PCommand palette (fuzzy finder over conversations and commands)
Tab / Shift+TabNext / previous conversation
PgUp / PgDnScroll messages (5 lines)
Ctrl+Left / Ctrl+RightResize sidebar

Normal mode

Press Esc to enter Normal mode. The cursor stops blinking and the mode indicator changes in the status bar.

Scrolling

KeyAction
j / kScroll down / up 1 line
J / KJump to previous / next message
Ctrl+D / Ctrl+UScroll down / up half page
g / GScroll to top / bottom

Actions

KeyAction
yCopy message body to clipboard
YCopy full line ([HH:MM] <sender> body) to clipboard
EnterOpen action menu on focused message
rOpen reaction picker on focused message
qReply to focused message (quote reply)
eEdit own outgoing message
fForward focused message
dDelete focused message
pPin / unpin focused message
sFilter sidebar conversations
QJump to quoted message
Ctrl+OJump back to previous position
nJump to next search result
NJump to previous search result
@Mention autocomplete (in Insert mode)

Cursor movement

KeyAction
h / lMove cursor left / right
w / bWord forward / back
0 / $Start / end of line

Editing

KeyAction
xDelete character at cursor
DDelete from cursor to end of line

Entering Insert mode

KeyAction
iInsert at cursor
aInsert after cursor
IInsert at start of line
AInsert at end of line
oInsert (clear buffer first)
/Insert with / pre-typed (for commands)

Insert mode (default)

Insert mode is the default on startup. You can type messages and commands directly.

KeyAction
EscSwitch to Normal mode
EnterSend message or execute command
Alt+Enter / Shift+EnterInsert newline (multi-line input)
Ctrl+WDelete word back
Backspace / DeleteDelete characters
Up / DownRecall input history
Left / RightMove cursor
Home / EndJump to start / end of line

Mouse

Mouse support is enabled by default (toggle in /settings).

ActionEffect
Click sidebar conversationSwitch to that conversation
Scroll wheel in chatScroll messages up/down
Click in input barPosition cursor
Scroll wheel in overlaysNavigate list items

Help overlay

Press /help (alias /h) to see all keybindings and commands at a glance. The help overlay dynamically reflects your active keybinding profile.

Input history

In Insert mode, press Up and Down to cycle through previously sent messages and commands. History is per-session (not persisted to disk). Your current draft is preserved while browsing history.

Features

Messaging

Send and receive 1:1 and group messages. Messages sent from your phone (or other linked devices) sync into the TUI automatically.

Attachments

  • Images – rendered inline based on the image_mode setting:
    • halfblock (default) – Unicode halfblock art, universal fallback
    • native – Kitty / iTerm2 / Sixel graphics protocols for higher-fidelity pixel rendering with proper cropping and flicker-free scrolling
    • none – skip image rendering entirely
  • Native images inside tmux – Kitty and iTerm2 escapes are wrapped in tmux’s DCS passthrough envelope so attachments still render as actual pixels. Requires tmux 3.3+ with set -g allow-passthrough on plus the SIGGY_IMAGE_PROTOCOL env var to name the outer terminal (auto-detection cannot see through tmux). See the Troubleshooting page.
  • Voice messages – audio attachments show as [voice ▶ name 0:12] (the duration is read from the Ogg Opus file). Press o (open) on the focused message to play it inline through a detected command-line player (mpv, ffplay, afplay, cvlc, paplay, or aplay, in that order); the status bar shows live progress (playing name 0:05 / 0:12) and pressing o again stops playback. Set audio_player in the config to use a specific command instead (see Configuration). If no player is available it falls back to opening the file in your OS default app.
  • Other files – shown as [attachment: filename] with the download path
  • Send files – use /attach to open a file browser and attach a file to your next message
  • Clipboard paste – use /paste to send images directly from your clipboard (e.g. screenshots). Text clipboard contents are inserted into the input buffer

Received attachments are saved to the download_dir configured in your config file (default: ~/signal-downloads/).

URLs and file paths in messages are rendered as OSC 8 hyperlinks. In supported terminals (Windows Terminal, iTerm2, Kitty, etc.), you can click them to open in your browser.

Typing indicators

When someone is typing, their name appears below the chat area. Contact name resolution is used where available. siggy also sends typing indicators to your conversation partners while you type, so they can see when you’re composing a message.

Persistence

All conversations, messages, and read markers are stored in a SQLite database with WAL (Write-Ahead Logging) mode for safe concurrent access. Data survives app restarts.

The database is stored alongside the config file:

  • Linux / macOS: ~/.config/siggy/siggy.db
  • Windows: %APPDATA%\siggy\siggy.db

Date separators

Day-boundary separator lines appear between messages from different days, showing “Today”, “Yesterday”, or the full date (e.g. “Mar 12, 2026”). Toggle via /settings > “Date separators” (enabled by default).

Unread tracking

The sidebar shows unread counts (e.g. (3)) next to each conversation with a colored dot indicator. When you open a conversation, a “new messages” separator line marks where you left off. Read markers persist across restarts.

Conversations automatically reorder to the top of the sidebar when messages are sent or received, so your most active chats are always visible.

Use /unread to flip a read conversation back to unread: the newest incoming message counts as unread again, the conversation closes, and the sidebar shows a badge. The marker persists across restarts.

Archive

Use /archive to hide the current conversation from the sidebar. Archived conversations behave like the official app:

  • Any new message (sent or received) automatically unarchives.
  • The sidebar footer shows · N archived when anything is hidden.
  • The sidebar filter (/_) lists every conversation including archived ones (marked with a muted a); open one and run /archive again to unarchive.

The archived flag persists in the local database.

Notifications

Terminal bell notifications fire when new messages arrive in background conversations. Configure them per type:

  • notify_direct – 1:1 messages (default: on)
  • notify_group – group messages (default: on)
  • desktop_notifications – OS-level desktop notifications (default: off)
  • /mute – per-conversation mute (persists in the database)
  • /bell – toggle notification types at runtime

Desktop notifications use notify-rust for cross-platform support (Linux D-Bus, macOS NSNotification, Windows WinRT toast). They show the sender name and a message preview, and respect the same mute/block/accept conditions as bell notifications.

Contact resolution

On startup, siggy requests your contact list and group list from signal-cli. Names from your Signal address book are used throughout the sidebar, chat area, and typing indicators.

Responsive layout

The sidebar auto-hides on narrow terminals (less than 60 columns). Use Ctrl+Left / Ctrl+Right to resize it, or /sidebar to toggle it.

Command palette

Press Ctrl+P to open a fuzzy finder over everything: conversations and slash commands in one list. Type to filter (fuzzy subsequence matching, so arc finds /archive and jke finds Jake), navigate with Up/Down, and press Enter to:

  • jump to a conversation, or
  • run a command – argument-less commands (like /help or /settings) execute immediately; commands that take arguments (like /join or /export) prefill the composer so you can finish typing them.

With an empty query, your conversations are listed first (in sidebar order) followed by all commands. Ctrl+P is bound in the Default and Minimal keybinding profiles; in Emacs it is left unbound (Ctrl+P is line-up there) but can be bound via command_palette in a keybindings override.

Press s in Normal mode to activate the sidebar filter. Type to narrow conversations by name – the sidebar title changes to show your filter text (e.g. /ali). Press Enter to jump to the first match, or Esc to cancel. Backspacing to empty also cancels. Tab/Shift-Tab conversation switching and mouse clicks automatically clear the filter.

Incognito mode

siggy --incognito

Uses an in-memory database instead of on-disk SQLite. No messages, conversations, or read markers are written to disk. The status bar shows a bold magenta incognito indicator. When you exit, everything is gone.

Session lock + boss key

Ctrl-L (or /lock) blanks the visible chat behind a passphrase prompt – think “boss key” for terminal sessions. The passphrase is stored as an argon2 PHC hash, never as plaintext. While locked, terminal bells and desktop notifications are suppressed and the window title is clamped to bare siggy so no unread count leaks. Unlock by typing the passphrase followed by Enter.

The first time you /lock, you set the passphrase. After that, lock is instant. Use /lock-reset (from inside the app, requires the current passphrase) to change it.

Forgot the passphrase? Quit siggy and run:

siggy --reset-lock

This deletes the stored hash file ({config_dir}/lock_hash) and prints the path it removed. The next /lock sets a fresh passphrase. By design there is no in-app recovery – the lock is a casual-snooping deterrent, not a defence against filesystem access.

Message reactions

React to any message with r in Normal mode to open the emoji picker. Navigate with h/l or press 1-8 to jump directly, then Enter to send.

Reactions display below messages as compact badges:

👍 2  ❤️ 1

Selecting the same emoji you already reacted with removes the reaction (toggle behavior), matching other Signal clients.

Enable “Verbose reactions” in /settings to show sender names instead of counts. To hide reactions entirely, disable “Show reactions” in /settings. Reactions sync across devices and persist in the database.

@mentions

In group chats, type @ to open a member autocomplete popup. Filter by name and press Tab to insert the mention. Works in 1:1 chats too (with the conversation partner). Incoming mentions are highlighted in cyan+bold.

Visible message selection

When scrolling in Normal mode, the focused message gets a subtle dark background highlight. This makes it clear which message r (react) and y/Y (copy) will target. Use J/K (Shift+j/k) to jump between messages, skipping date separators and system messages.

Reply, edit, and delete

In Normal mode, act on the focused message:

  • q – Quote reply – reply with a quoted block showing the original message. A reply indicator appears above your input while composing.
  • e – Edit – edit your own outgoing messages. The original text is loaded into the input buffer. Edited messages display “(edited)”.
  • d – Delete – delete a message. Outgoing messages offer “delete for everyone” (remote delete) or “delete locally”. Incoming messages can be deleted locally. Deleted messages show as “[deleted]”.

All three features sync across devices and persist in the database.

Jump to quoted message

When a message contains a quoted reply, press Q in Normal mode to jump to the original quoted message. The viewport scrolls to show the original in context. Press Ctrl+O to jump back to where you were. Multiple jumps stack, so you can follow a chain of quotes and unwind them all.

If the quoted message is not in the loaded message history, a status message indicates it’s too far back.

Message search

Use /search <query> (alias /s) to search across all conversations. Results appear in a scrollable overlay with sender, snippet, and conversation name. Press Enter to jump to the message in context.

After searching, use n/N in Normal mode to cycle through matches without re-opening the overlay.

Text styling

Signal formatting is rendered in the chat area:

  • Bold – displayed with terminal bold
  • Italic – displayed with terminal italic
  • Strikethrough – displayed with terminal strikethrough
  • Monospace – displayed in gray
  • Spoiler – hidden behind block characters (████); focus the message with J/K in Normal mode to reveal the text while focused

Styles compose correctly with @mentions and link highlighting.

Sending formatted text

Wrap text in markers as you type and siggy converts them to Signal style ranges on send (the markers are stripped from the delivered message):

MarkerStyle
*bold*Bold
_italic_Italic
~strikethrough~Strikethrough
`monospace`Monospace
||spoiler||Spoiler

Markers only take effect when they wrap text directly (no spaces inside) and sit on word boundaries, so snake_case, 2 * 3, and URLs containing underscores are sent unchanged. A marker pair cannot span multiple lines. Unmatched markers are sent literally. Formatting also applies when editing a message, but note that editing re-opens the plain text without markers, so re-add them if you want the edit to stay formatted.

Sticker messages

Incoming stickers render as inline images (like photo attachments) when signal-cli has the sticker pack cached locally, using your configured image_mode. When the pack is not cached, they fall back to a [Sticker: emoji] placeholder (e.g. [Sticker: 👍]), or [Sticker] when the sticker has no associated emoji.

View-once messages

View-once messages display as [View-once message] with any attachments suppressed, respecting the sender’s ephemeral intent.

System messages

Certain Signal events display as system messages (dimmed, centered) in the chat:

  • Missed calls – “Missed voice call” / “Missed video call”
  • Safety number changes – warning when a contact’s safety number changes
  • Group updates – group metadata changes (member adds/removes)
  • Disappearing message timer – e.g. “Disappearing messages set to 1 day”

Message action menu

Press Enter in Normal mode on a focused message to open a contextual action menu. Available actions depend on the message type:

ActionKeyAvailable on
ReplyqNon-deleted messages
EditeYour own outgoing messages
ReactrAll messages
CopyyAll messages
ForwardfNon-deleted messages
DeletedNon-deleted messages

Navigate with j/k, press Enter to execute, or press the shortcut key directly. Press Esc to close.

Read receipts

siggy sends read receipts to message senders when you view a conversation, letting them know you’ve read their messages. This can be toggled off via /settings > “Send read receipts”.

Cross-device read sync

When you read messages on your phone or another linked device, siggy receives the read sync and marks those conversations as read. Unread counts update automatically.

Disappearing messages

siggy honors Signal’s disappearing message timers. When a conversation has a timer set, messages auto-expire after the configured duration. Set the timer with /disappearing <duration> (alias /dm):

  • 30s, 5m, 1h, 1d, 1w – set the timer
  • off – disable disappearing messages

Timer changes from other devices sync automatically.

Group management

Use /group (alias /g) to manage groups directly from the TUI:

  • View members – see all group members
  • Add member – type-to-filter contact picker to add members
  • Remove member – type-to-filter member picker to remove members
  • Rename – change the group name
  • Create – create a new group (available from any conversation)
  • Leave – leave the group with confirmation

Message requests

Messages from unknown senders (not in your contacts) are flagged as message requests. A banner appears at the top of the conversation with options to accept or delete. Unaccepted conversations do not trigger notifications or send read receipts.

Block and unblock

Use /block to block the current conversation’s contact or group, and /unblock to unblock. Blocked conversations do not trigger notifications, read receipts, or typing indicators.

Mouse support

Mouse support is enabled by default. Toggle via /settings > “Mouse support”.

  • Click sidebar – switch conversations by clicking
  • Scroll messages – scroll wheel in the chat area
  • Click input bar – position the cursor by clicking
  • Overlay scroll – scroll wheel navigates lists in overlays

Color themes

Open the theme picker with /theme (alias /t) or from /settings > Theme. Choose from built-in themes with customizable sidebar, chat, status bar, and accent colors.

Custom themes

Drop a *.toml theme file in your themes directory and it appears in the picker:

  • Linux / macOS: ~/.config/siggy/themes/
  • Windows: %APPDATA%\siggy\themes\

The repo ships a fully-commented starting point at themes/custom-theme-template.toml: copy it in, edit the colors (named 16-color values, #rrggbb hex, or indexed(N) 256-color), set a unique name, and pick it via /theme.

Pinned messages

Pin important messages to the top of a conversation. Press p in Normal mode on a focused message (or use the action menu) to pin it. Choose a duration: forever, 24 hours, 7 days, or 30 days. Pinned messages show as a banner at the top of the chat area. Press p on an already-pinned message to unpin it. Pin state syncs across all linked devices.

Incoming messages containing URLs display link preview cards with the page title, description, and thumbnail image (when available). Toggle via /settings > “Link previews” (enabled by default).

Sending previews

Signal previews are generated by the sender, so to send one, run:

/preview https://example.com/article

siggy fetches the page in the background, extracts the Open Graph metadata (title, description, thumbnail), and shows preview: <title> on the composer. Your next message in any conversation carries the preview card; if your text does not contain the URL, siggy appends it (Signal requires the URL in the message body). Run /preview with no argument to discard a pending preview.

Previews are never fetched automatically while you type: fetching a URL reveals your IP address to that site, so it only happens on the explicit command.

Polls

Create polls with /poll "question" "option1" "option2". Add --single to restrict voting to one option. Polls display inline as bar charts showing vote counts and percentages.

Press Enter on a poll message in Normal mode to open the vote overlay. Select options with Space (multi-select) or Enter (single-select), then confirm. Your votes sync across devices.

Identity verification

Use /verify to verify the identity keys of your contacts. In 1:1 chats, the overlay shows the contact’s safety number and current trust level. In group chats, browse members and verify individually. You can trust or untrust identity keys directly from the overlay.

Profile editor

Use /profile to edit your Signal profile. Change your given name, family name, about text, and about emoji. Navigate fields with j/k, press Enter to edit inline, and Save to push changes to Signal’s servers.

About

Use /about to see the app version, description, author, license, and repository link.

The sidebar can be placed on the left (default) or right side of the screen. Toggle via /settings > “Sidebar on right”.

Configurable keybindings

All keybindings are fully configurable. Choose from three built-in profiles (Default, Emacs, Minimal) or create your own. Override individual keys via ~/.config/siggy/keybindings.toml, or rebind keys live in the app with /keybindings (alias /kb).

See Keybindings for full details on profiles, customization, and the TOML format.

Emoji-to-text mode

Enable “Emoji to text” in /settings to convert emoji to text representations at render time. Common emoji get classic emoticons (:) :( ;) :D <3 +1 etc.), while all other emoji get Discord/Slack-style :shortcodes: (e.g. :fire: :wave:).

This is display-only – stored messages keep the original emoji. Applies to message bodies, quoted replies, system messages, and reaction summaries.

Multi-line input

Press Alt+Enter or Shift+Enter in Insert mode to insert a newline. Compose multi-line messages before sending with Enter. The input area expands automatically to show all lines.

Message history pagination

Scrolling to the top of a conversation automatically loads older messages from the database. A loading indicator appears briefly while fetching. This lets you browse your full message history without loading everything upfront.

Forward messages

Press f in Normal mode on a focused message to forward it to another conversation. A filterable picker overlay lets you choose the destination.

Export chat history

Use /export to save the active conversation’s messages to a file in your Downloads directory as siggy-export-<name>-<date>.<ext>. Three formats are available:

  • /export or /export txt - plain text in a simple IRC-style format
  • /export md - Markdown, nicer for reading and sharing
  • /export json - structured output for scripting (pipe through jq)

Add a number to export only the last N messages, in either order: /export md 100 or /export 100 md.

All formats include timestamps, sender names, message bodies, “(edited)” labels, quoted replies, and reactions. JSON additionally carries sender IDs, millisecond timestamps, and deleted/system flags.

Demo mode

siggy --demo

Launches with dummy conversations and messages. No signal-cli process is spawned. Useful for testing the UI, exploring keybindings, and taking screenshots.

Automation and scheduling

siggy exposes a small non-interactive CLI (no TUI) for scripting. See the CLI flags table in Configuration for the full list; the automation-relevant ones are --check (guard), --send, --list, and --receive. Output is plain or tab-separated stdout with scriptable exit codes.

# guard, then send
siggy --check && siggy --send +15551234567 "build passed"

# stream incoming messages and auto-reply to a keyword
siggy --receive | while IFS=$'\t' read -r ts from group body; do
  case "$body" in *ping*) siggy --send "$from" "pong";; esac
done

One instance per account. signal-cli allows only one process per account at a time, so --send and --receive cannot run while the siggy TUI is open (or while another --receive is streaming). Close the running instance first. If you forget, the command reports “another siggy or signal-cli instance is using this account”. --check and --list are unaffected: --check only queries the signal-cli version (no account lock) and --list reads the cached database.

Message triggers

Rules in triggers.toml (next to config.toml) auto-respond to incoming messages: “on a message matching X from Y, reply Z and/or run a command”. The same rules fire in two places: inside the TUI while you chat, and in a headless siggy --watch mode for always-on bots (remember: one signal-cli process per account, so --watch and the TUI cannot run simultaneously).

# Any run rule stays dormant without this explicit opt-in.
allow_run = true

[[trigger]]
match = "ping"              # case-insensitive; also: match_mode = "exact" | "prefix"
match_mode = "exact"
from = "+15551234567"       # optional sender filter (recommended)
reply = "pong"

[[trigger]]
match = "garage"
conversation = "Home"       # conversation id or exact name
run = ["C:/scripts/garage.ps1"]   # argv array - never a shell string
rate_limit_secs = 60        # per rule per conversation (default 30)

Safety rails, because message bodies are controlled by whoever messages you:

  • run commands are spawned directly from the argv array (no shell), and the message arrives as JSON on stdin - message content can never become command arguments. run rules require the top-level allow_run = true.
  • Rules never fire on your own messages, nor on history replayed by the initial sync (only messages newer than engine start).
  • Each rule is rate limited per conversation, so two auto-responders cannot reply to each other forever.
  • Rules with no from/conversation filter load with a warning.

In the TUI, /triggers reloads the file and reports the rule count; warnings go to the debug log. In --watch mode, each firing logs a tab-separated line to stdout.

Scheduled messages

There is no built-in scheduler; use your OS scheduler with siggy --send, which keeps siggy dependency-free and reuses the scheduler you already trust.

cron (Linux / macOS) - send a reminder every weekday at 9am:

0 9 * * 1-5  siggy --send +15551234567 "standup in 5"

systemd timer (Linux) - a siggy-reminder.service running ExecStart=siggy --send +15551234567 "standup in 5", paired with a siggy-reminder.timer (OnCalendar=Mon..Fri 09:00).

Task Scheduler (Windows) - schedule a task whose action runs siggy.exe --send +15551234567 "standup in 5".

--send exits 0 only once the message is confirmed sent, so a wrapping script can detect and retry failures. The account must already be linked (check with siggy --check).

Security

Trust model

siggy is a thin TUI layer over signal-cli. It does not implement any cryptographic protocols, manage credentials, or contact Signal servers directly. All security-critical operations are delegated to signal-cli, which implements the full Signal Protocol.

This means siggy inherits signal-cli’s security posture:

  • What signal-cli handles: key generation, key exchange, message encryption/decryption, identity verification, contact and group management, attachment encryption, and all network communication with Signal servers.
  • What siggy handles: rendering messages in a terminal, storing a local cache of conversations in SQLite, and forwarding user actions to signal-cli via JSON-RPC.

siggy never sees plaintext cryptographic keys or raw network traffic.

Credential storage

Signal credentials (identity keys, session keys, pre-keys) are stored by signal-cli in its own data directory (~/.local/share/signal-cli/ on Linux). siggy does not read, write, or manage these files. If credential storage security is a concern, it should be addressed at the signal-cli level or via OS-level protections (encrypted home directory, restrictive file permissions).

Encryption

In transit

All messages are end-to-end encrypted using the Signal Protocol, handled entirely by signal-cli. siggy communicates with signal-cli over a local stdin/stdout pipe using JSON-RPC – no network sockets are involved.

At rest

Messages are stored unencrypted in a local SQLite database (siggy.db). This is the same approach used by Signal Desktop and most other messaging clients. The rationale is that local storage protection is best handled at the OS level (full-disk encryption, screen lock, file permissions) rather than by individual applications.

The database uses PRAGMA secure_delete = ON, which zeroes out deleted content in the database file rather than leaving it recoverable in free pages.

Files on disk

FileContentsLocation
siggy.dbMessage history, contacts, groupsPlatform config directory
siggy.db-walRecent uncommitted writesSame directory
config.tomlPhone number, settingsPlatform config directory
debug.logDebug output (opt-in, PII redacted by default)~/.cache/siggy/
Download directoryReceived attachments~/signal-downloads/ or configured path

Platform config directories:

  • Linux / macOS: ~/.config/siggy/
  • Windows: %APPDATA%\siggy\

File permissions

On Unix (Linux / macOS), siggy creates its sensitive files and directories with restrictive permissions: the lock hash, debug log, and their parent directories are set to owner-only (0600 for files, 0700 for directories).

On Windows there is no portable equivalent of chmod, so siggy does not set an explicit DACL. Instead it relies on the default per-user ACLs that Windows applies to the profile directories these files live in (%APPDATA%, %USERPROFILE%, %LOCALAPPDATA%). Under those defaults other standard user accounts cannot read your siggy files, but local administrators still can. If that is part of your threat model, enable BitLocker (full-volume encryption) or store your profile on an encrypted volume. This is a deliberate choice over manipulating Win32 security descriptors, which could lock you out of your own files if it went wrong.

Privacy features

Incognito mode

siggy --incognito

Uses an in-memory database instead of on-disk SQLite. No messages, conversations, or read markers are written to disk. When you exit, everything is gone.

Notification previews

Desktop notification content is configurable via the notification_preview setting in /settings:

LevelTitleBody
full (default)Sender nameMessage content
senderSender name“New message”
minimal“New message”(empty)

Debug logging

Debug logging is opt-in only and disabled by default.

  • --debug – enables logging with PII redaction: phone numbers are masked (e.g. +4***...567), message bodies are replaced with [msg: 42 chars], and contact/group lists show only counts.
  • --debug-full – enables logging with full unredacted output. Only use this when you need actual message content for troubleshooting, and delete the log file afterwards.

Debug logs are written to ~/.cache/siggy/debug.log with 10 MB rotation. On Unix systems, the log file and directory are created with restrictive permissions (0600 / 0700). See File permissions for how this differs on Windows.

Chat exports (/export) and the unredacted --debug-full log have terminal control characters stripped on write (newlines and tabs are kept), so opening either with cat/type cannot execute escape sequences embedded in a message body. Desktop notification titles and previews are likewise stripped of control characters before being handed to the OS notifier.

Clipboard

Copied message content is automatically cleared from the system clipboard after 30 seconds (configurable via clipboard_clear_seconds in config).

Session lock

Ctrl-L (or /lock) blanks the chat behind a passphrase prompt for the “someone walked up to my terminal” case. Set lock_timeout = N (minutes) in the config to also auto-lock after N minutes without a keypress (0, the default, disables it; mouse movement does not count as activity). The passphrase is stored as an argon2 PHC hash at {config_dir}/lock_hash, never as plaintext. While locked:

  • Keyboard input is intercepted; nothing reaches the composer
  • Terminal bell and OS desktop notifications are suppressed
  • Window title is clamped to bare siggy so the unread count does not leak

Threat model. Session lock is a deterrent against casual on-screen snooping, not a defence against an attacker with shell access. Anyone who can read your home directory can also delete lock_hash and bypass the prompt, which is the same escape hatch the maintainer uses to recover from a forgotten passphrase (siggy --reset-lock). If you need stronger at-rest protection for the message DB, rely on full-disk encryption.

Recommendations

  • Enable full-disk encryption on your device (BitLocker, LUKS, FileVault). This is the single most effective protection for data at rest.
  • Use --incognito mode for sensitive sessions where you don’t want any messages persisted to disk.
  • Set notification_preview = "sender" or "minimal" if you’re concerned about notification content being visible on lock screens or in screen recordings.
  • Use a screen lock to prevent physical access to your terminal session.
  • On shared Unix systems, restrict file permissions on the config directory (chmod 700 ~/.config/siggy).
  • On Windows, the config and cache directories inherit your user profile’s default ACLs (other standard users cannot read them; local administrators can). Enable BitLocker if you need protection beyond that. See File permissions.

Reporting vulnerabilities

If you discover a security issue, please report it responsibly via GitHub Issues or contact the maintainer directly. We take security seriously and will respond promptly.

Troubleshooting

signal-cli not found

Symptom: setup wizard says it cannot find signal-cli.

Fix: ensure signal-cli is installed and on your PATH. You can also set the full path in your config:

signal_cli_path = "/usr/local/bin/signal-cli"

On Windows, use the full path to signal-cli.bat.

QR code doesn’t display properly

Symptom: the QR code appears garbled or too large during device linking.

Fix: make sure your terminal is at least 60 columns wide and supports Unicode block characters. Try a modern terminal emulator like Windows Terminal, iTerm2, Kitty, or Alacritty.

“Java not found” or class version errors

Symptom: signal-cli fails to start with Java-related errors, or you see UnsupportedClassVersionError mentioning “class file version 69.0”.

Fix: signal-cli 0.14+ requires Java 25+. Install a compatible JDK:

# Windows
winget install EclipseAdoptium.Temurin.25.JDK

# macOS
brew install --cask temurin@25

# Or download from https://adoptium.net/

Verify with java -version – you should see version 25 or higher. On Linux, the install script uses the native signal-cli build which does not require Java.

Messages not appearing

Symptom: the app starts but no messages show up.

Fix:

  1. Check that your device is properly linked in Signal’s settings on your phone (Settings > Linked Devices)
  2. Try re-running the setup wizard: siggy --setup
  3. Check signal-cli can communicate by running it directly:
    signal-cli -a +15551234567 receive
    

Messages from other devices aren’t syncing

Symptom: messages you send or receive on your phone (or another linked device) while siggy is closed do not show up when you reopen siggy, and the status bar briefly shows a signal-cli error such as getServerGuid(...) must not be null or Method not implemented.

Why: siggy receives messages only while it is running, because it runs signal-cli as a child process. Nothing syncs while siggy is closed. On reopen, signal-cli drains the messages the server queued for it. If signal-cli is outdated or its session has drifted, it can fail to process some of those queued messages, and they never reach siggy.

Fix:

  1. Upgrade signal-cli to the latest version. siggy uses newer signal-cli features, and an older build is also more likely to fail decrypting queued messages. Check your version with signal-cli --version.
  2. If it persists, re-link the device by re-running siggy --setup. Re-linking resets the session. Note that it does not back-fill history, and messages that were already dropped will not reappear.
  3. Linked devices only receive while connected, so for uninterrupted history keep siggy (or another always-on signal-cli client) running.

Images not rendering

Symptom: images show as [attachment: image.jpg] instead of inline previews.

Fix: make sure image_mode in your config is not set to "none". The default "halfblock" works in any terminal with truecolor / 256-color and proper Unicode support. "native" uses the Kitty / iTerm2 graphics protocol where supported, with automatic fallback otherwise.

Native images render as halfblock inside tmux

Symptom: outside tmux, image attachments render as actual pixels. Inside tmux they fall back to halfblock even though the outer terminal supports a native protocol.

Why: tmux strips Kitty (ESC _G...) and iTerm2 (ESC ]1337;...) escapes unless they are wrapped in tmux’s DCS passthrough envelope, and TERM_PROGRAM becomes tmux so auto-detection cannot see the outer terminal.

Fix: two steps. First, in your ~/.tmux.conf:

set -g allow-passthrough on

(Older tmux uses set -g allow-passthrough all. Requires tmux 3.3+.) Then launch siggy with an explicit protocol override that names the outer terminal:

SIGGY_IMAGE_PROTOCOL=kitty siggy        # or iterm2 / sixel / halfblock

Sixel passes through tmux 3.4+ natively and does not need the env var.

Symptom: the sidebar is not visible.

Fix: if your terminal is narrower than 60 columns, the sidebar auto-hides. Widen your terminal, or press /sidebar to force it on. You can also use Ctrl+Right to widen the sidebar.

Database errors

Symptom: errors about SQLite or the database file.

Fix: the database is stored alongside the config file. If it becomes corrupted, you can delete it and siggy will create a fresh one on next launch. You’ll lose message history but all conversations will re-populate from signal-cli.

As a workaround, you can also run in incognito mode:

siggy --incognito

FAQ

Does siggy replace the Signal phone app?

No. siggy runs as a linked device, just like Signal Desktop. Your phone remains the primary device and must stay registered. siggy connects through signal-cli, which registers as a secondary device on your account.

Can I use siggy without a phone?

No. Signal requires a phone number for registration and a primary device. siggy links to your existing account as a secondary device.

Is my data encrypted?

Messages are end-to-end encrypted in transit by the Signal protocol (handled by signal-cli). Locally, messages are stored in an unencrypted SQLite database – the same approach used by Signal Desktop. If you want zero local persistence, use --incognito mode. See the Security page for full details and recommendations.

Can I send files and images?

Yes. Use /attach to open a file browser and select a file to send. Received images are rendered inline, and other files are saved to your download directory.

Does it work on Windows?

Yes. Pre-built Windows binaries are provided in each release. Use a modern terminal like Windows Terminal for the best experience (clickable links, proper Unicode, truecolor support).

Does it work over SSH?

Yes. siggy is a terminal application and works perfectly over SSH sessions. Make sure signal-cli and Java are available on the remote machine.

Can I use multiple Signal accounts?

Yes. Use the -a flag or config file to specify which account to use:

siggy -a +15551234567
siggy -a +15559876543

Each account needs its own device linking via signal-cli.

I locked the session and forgot my passphrase. Now what?

Quit siggy (or kill the process if the lock screen is in the way) and run:

siggy --reset-lock

The flag deletes the stored passphrase hash file and prints the path it removed. The next time you /lock, you will be prompted to set a fresh passphrase. By design there is no in-app recovery – the lock is a casual-snooping deterrent, not protection against file-system access.

Images aren’t rendering as native pixels inside tmux

tmux strips the Kitty / iTerm2 graphics escapes unless you opt into passthrough, and TERM_PROGRAM becomes tmux so siggy cannot auto-detect the outer terminal. Two steps:

  1. In ~/.tmux.conf: set -g allow-passthrough on (requires tmux 3.3+).
  2. Launch siggy with the outer terminal’s protocol named: SIGGY_IMAGE_PROTOCOL=kitty siggy (or iterm2, sixel, halfblock).

See the Troubleshooting page for details.

How do I update siggy?

Re-run the install script, or download the latest binary from the Releases page.

If you installed from source:

cargo install --git https://github.com/johnsideserf/siggy.git --force

What license is siggy under?

AGPL-3.0. This is a copyleft license – forks must remain open source under the same terms.

Architecture

Overview

siggy is a terminal Signal client that wraps signal-cli via JSON-RPC over stdin/stdout. It is built on a Tokio async runtime with Ratatui for rendering.

graph TB
    subgraph main["Main Thread"]
        KB["Keyboard Input<br/><i>crossterm poll 50ms</i>"]
        APP["App State<br/><i>app.rs + domain/</i>"]
        UI["Ratatui Renderer<br/><i>ui/</i>"]
        DB["SQLite<br/><i>db.rs · WAL mode</i>"]
    end

    subgraph tokio["Tokio Tasks"]
        SR["stdout reader<br/><i>parse JSON-RPC</i>"]
        SW["stdin writer<br/><i>send JSON-RPC</i>"]
    end

    CLI["signal-cli<br/><i>child process</i>"]

    KB -- "InputAction" --> APP
    APP -- "&mut App" --> UI
    APP -- "persist" --> DB
    DB -- "load" --> APP
    APP -- "JsonRpcRequest<br/>(mpsc)" --> SW
    SW -- "stdin" --> CLI
    CLI -- "stdout" --> SR
    SR -- "SignalEvent<br/>(mpsc)" --> APP

Async runtime

The application uses a multi-threaded Tokio runtime (via #[tokio::main]). The main thread runs the TUI event loop. signal-cli communication happens in spawned Tokio tasks that communicate back to the main thread via tokio::sync::mpsc channels.

Event loop

The main loop in main.rs runs on a 50ms tick:

flowchart LR
    A["Poll keyboard<br/><i>50ms timeout</i>"] --> B["Drain signal<br/>events"]
    B --> C["Update state"]
    C --> D{"Needs<br/>redraw?"}
    D -- yes --> E["Render frame<br/><i>ui::draw()</i>"]
    D -- no --> F["Maintenance<br/><i>typing, expiry,<br/>receipts</i>"]
    E --> F
    F --> A

This keeps the UI responsive while processing backend events as they arrive.

Startup sequence

sequenceDiagram
    participant M as main.rs
    participant C as Config
    participant S as Setup Wizard
    participant L as Link Flow
    participant DB as SQLite
    participant SC as SignalClient

    M->>C: Load TOML config
    alt account field empty
        M->>S: Run setup wizard
        S->>L: Device linking (QR code)
        L-->>M: Account registered
    end
    M->>DB: Open database
    M->>SC: Spawn signal-cli
    SC-->>M: Connected
    M->>SC: Sync contacts, groups, identities
    M->>M: Enter event loop

Key dependencies

CratePurpose
ratatui 0.30Terminal UI framework
crossterm 0.29Cross-platform terminal I/O
tokio 1.xAsync runtime
serde / serde_jsonJSON serialization for signal-cli RPC
rusqlite 0.40SQLite database (bundled)
chrono 0.4Timestamp handling
qrcode 0.14QR code generation for device linking
image 0.25Image decoding for inline previews
icy_sixel 0.5Sixel image encoding
arboard 3.xClipboard access for /paste (with Wayland data-control)
argon2 0.5Session-lock passphrase hashing
notify-rust 4.xDesktop notifications
emojis 0.9Emoji lookup and shortcodes
open 5.xOpen attachments/URLs in the OS default app
anyhow 1.xError handling
toml 1.xConfig file parsing
dirs 6.xPlatform-specific directory paths
uuid 1.xRPC request ID generation

Module Reference

siggy is organized under src/ with a few submodule trees (handlers/, domain/, signal/, ui/) around a flat core.

Module dependency graph

graph TD
    MAIN["main.rs<br/><i>entry point + event loop</i>"]
    APP["app.rs<br/><i>application state</i>"]
    HANDLERS["handlers/<br/><i>event + key handling</i>"]
    DOMAIN["domain/<br/><i>extracted App sub-state</i>"]
    STORE["conversation_store.rs<br/><i>conversations + ordering</i>"]
    UI["ui/<br/><i>rendering</i>"]
    CLIENT["signal/client.rs<br/><i>signal-cli process</i>"]
    PARSE["signal/parse/<br/><i>JSON-RPC parsing</i>"]
    TYPES["signal/types.rs<br/><i>shared types</i>"]
    DB["db.rs<br/><i>SQLite persistence</i>"]
    CONFIG["config.rs<br/><i>TOML config</i>"]
    INPUT["input.rs<br/><i>command parsing</i>"]
    SETUP["setup.rs<br/><i>first-run wizard</i>"]
    LINK["link.rs<br/><i>device linking</i>"]

    MAIN --> APP
    MAIN --> UI
    MAIN --> CLIENT
    MAIN --> CONFIG
    MAIN --> SETUP
    MAIN --> DB
    SETUP --> LINK
    APP --> HANDLERS
    APP --> DOMAIN
    APP --> STORE
    APP --> DB
    APP --> TYPES
    APP --> INPUT
    CLIENT --> PARSE
    PARSE --> TYPES
    UI --> APP

Source files

main.rs

Entry point. Parses CLI arguments (including the non-interactive --version / --check / --send / --list / --receive / --reset-account / --reset-lock modes), runs the setup wizard if needed, opens the database, spawns signal-cli, and runs the main event loop.

The event loop polls keyboard input (50ms timeout), drains signal events from the mpsc channel, and renders each frame with ui::draw(). dispatch_send() routes each SendRequest variant to its SignalClient RPC method, so handlers stay synchronous and the main loop owns all async I/O. A supervisor respawns signal-cli with backoff if the child exits.

app.rs

Application state. The App struct holds the mode (Normal / Insert), the active conversation, and sub-structs extracted into src/domain/. A CI ratchet (scripts/check-app-field-count.sh) fails PRs that grow the direct field count past its committed baseline, which forces new state into domain/ sub-structs.

conversation_store.rs

ConversationStore: the conversation map (HashMap keyed by phone number or group ID), the ordered Vec for sidebar display, contact/UUID name lookups, and read markers. get_or_create_conversation() is the single point for ensuring a conversation exists – it upserts to both memory and SQLite.

handlers/

Event and key handling extracted from app.rs:

  • handlers/signal.rshandle_signal_event(), the single entry point for all backend events (messages, receipts, typing, contact/group lists, errors).
  • handlers/input.rs – composer text to SendRequest (send, edit, poll, archive, export, and the other slash-command arms).
  • handlers/keys.rs – global, Normal-mode, Insert-mode, overlay, and mouse handlers.

domain/

Extracted App sub-state, one module per concern: scroll, input, pending sends, overlays, image cache, lock, typing, search, mouse hit-areas, media (download dir + audio player), and the shared SendRequest value type. domain/ is a leaf layer: it does not import from app::.

signal/client.rs

Spawns the signal-cli child process and manages communication. Two Tokio tasks:

  • stdout reader – reads lines from signal-cli stdout, parses JSON-RPC into SignalEvent variants, and sends them through the mpsc channel
  • stdin writer – receives JsonRpcRequest structs and writes them as JSON lines to signal-cli stdin

The pending_requests map tracks RPC call IDs to correlate responses with their original method (e.g., mapping a response ID back to listContacts). All send_* methods build params and go through a shared send_rpc helper.

signal/parse/

JSON-RPC to SignalEvent parsing, split by concern: envelope.rs, message.rs, helpers.rs (attachments, mentions, text styles), rpc.rs, and poll.rs.

signal/types.rs

Shared types for signal-cli communication:

  • SignalEvent – enum of all events the backend can produce
  • SignalMessage – a message with source, timestamp, body, attachments, group info, text styles
  • TextStyle / StyleType – text formatting ranges (bold, italic, strikethrough, monospace, spoiler)
  • Attachment – file metadata (content type, filename, local path)
  • JsonRpcRequest / JsonRpcResponse – JSON-RPC protocol structs
  • Contact / Group – address book and group info

ui/

Rendering: mod.rs (draw()), chat_pane.rs, sidebar.rs, status_bar.rs, composer.rs, overlays/ (one file per overlay), and links.rs (URI span styling and the OSC 8 hyperlink post-render pass).

Note that draw() takes &mut App: it writes layout feedback (scroll clamping, focus derivation, mouse hit-rects) during render.

db.rs

SQLite database layer with WAL mode. Tables: conversations, messages, read_markers, reactions, poll_votes. Schema migration is a static, version-based table (currently at v15, see Database Schema).

Provides open() for disk-backed storage and open_in_memory() for incognito mode.

config.rs

TOML configuration serialized with serde. Core fields: account (E.164 phone), signal_cli_path, download_dir, db_path, plus UI and behavior preferences (image_mode, theme, lock_timeout, audio_player, notification toggles, and more – see Configuration for the full table). All fields have serde defaults so old config files keep loading.

input.rs

Input parsing. Converts composer text into an InputAction enum, covering every slash command and its aliases, and defines CommandInfo / the COMMANDS constant used by autocomplete and /help. Part of the fuzzable lib target (fuzz_command_parse, fuzz_input_edit).

Other modules

  • audio.rs – inline voice-message playback via a detected CLI player
  • compose.rs – composer markup parsing (*bold* etc.) to Signal style ranges
  • export.rs/export rendering (plain text, Markdown, JSON)
  • autocomplete.rs – command and @mention completion state
  • list_overlay.rs – shared list-overlay key/nav/scroll helpers
  • keybindings.rs / theme.rs – keybinding profiles and color themes (both TOML-extensible)
  • image_render.rs – halfblock/Kitty/iTerm2/Sixel image rendering
  • mute.rs – mute state (permanent and timed)
  • debug_log.rs – optional file logger gated by --debug
  • demo.rs--demo dummy data
  • fs_migrate.rs – signal-tui to siggy path migration

setup.rs

Multi-step first-run wizard. Handles signal-cli detection (searching PATH), phone number input with validation, relink safety checks against existing local data, and triggers the device linking flow.

link.rs

Device linking flow. Runs signal-cli’s link command, captures the QR code URI, renders it in the terminal, and waits for the user to scan it with their phone. Surfaces signal-cli’s stderr on failure with recovery guidance (e.g. the 409 device-conflict case).

Data Flow

Outbound messages (user sends)

sequenceDiagram
    participant U as User
    participant I as input.rs
    participant A as App
    participant SC as SignalClient
    participant CLI as signal-cli

    U->>I: Type message + Enter
    I->>A: InputAction::SendText
    A->>A: Add message locally<br/>(optimistic display)
    A->>A: Persist to SQLite
    A->>SC: JsonRpcRequest (mpsc)
    SC->>CLI: JSON-RPC via stdin
    CLI-->>SC: Response with server timestamp
    SC-->>A: SignalEvent::SendResult
    A->>A: Update message status<br/>(Sending → Sent)

The request is a JSON-RPC call to the send method with the recipient and message body as parameters. Each request gets a unique UUID as its RPC ID.

Inbound messages (received)

sequenceDiagram
    participant CLI as signal-cli
    participant SR as stdout reader
    participant A as App
    participant DB as SQLite
    participant UI as ui/

    CLI->>SR: JSON-RPC notification<br/>(method: "receive")
    SR->>A: SignalEvent::MessageReceived<br/>(mpsc channel)
    A->>A: get_or_create_conversation()
    A->>A: Append to message list
    A->>DB: Insert message
    A->>A: Update unread count
    A->>A: Reorder sidebar
    Note over A: Terminal bell<br/>(if enabled + not muted)
    A->>UI: Next render cycle

RPC request/response correlation

signal-cli uses JSON-RPC 2.0. There are two types of messages:

Notifications (incoming)

Notifications arrive as JSON-RPC requests from signal-cli (they have a method field). These include:

  • receive - incoming message
  • receiveTyping - typing indicator
  • receiveReceipt - delivery/read receipt

These are unsolicited and do not have an id field matching any outbound request.

RPC responses

When siggy sends a request (e.g., listContacts, listGroups, send), signal-cli replies with a response that has a matching id field and a result (or error) field.

sequenceDiagram
    participant S as siggy
    participant CLI as signal-cli

    S->>CLI: {"id": "abc-123",<br/>"method": "listContacts"}
    Note over S: pending_requests["abc-123"]<br/>= "listContacts"
    CLI-->>S: {"id": "abc-123",<br/>"result": [...]}
    Note over S: Lookup method by ID<br/>→ parse as Vec<Contact><br/>→ emit ContactList

The pending_requests map in SignalClient stores id → method pairs. When a response arrives, the client looks up the method by ID to know how to parse the result.

Sync messages

When you send a message from your phone, signal-cli receives a sync notification. These appear as SignalMessage with is_outgoing = true and a destination field indicating the recipient. The app routes these to the correct conversation and displays them as outgoing messages.

Channel architecture

graph LR
    subgraph tokio["SignalClient (tokio tasks)"]
        SR["stdout reader"]
        SW["stdin writer"]
    end

    subgraph main["App (main thread)"]
        APP["App state"]
    end

    SR -- "SignalEvent<br/>(mpsc, unbounded)" --> APP
    APP -- "JsonRpcRequest<br/>(mpsc, unbounded)" --> SW

Both channels are unbounded tokio::sync::mpsc channels. The signal event channel carries SignalEvent variants. The command channel carries JsonRpcRequest structs to be serialized and written to signal-cli’s stdin.

Database Schema

siggy uses SQLite with WAL (Write-Ahead Logging) mode for safe concurrent reads/writes. The database file is stored alongside the config file.

Tables

schema_version

Tracks the current migration version.

CREATE TABLE schema_version (
    version INTEGER NOT NULL
);

conversations

One row per conversation (1:1 or group).

CREATE TABLE conversations (
    id                TEXT PRIMARY KEY,      -- phone number or group ID
    name              TEXT NOT NULL,         -- display name
    is_group          INTEGER NOT NULL DEFAULT 0,
    created_at        TEXT NOT NULL DEFAULT (datetime('now')),
    muted             INTEGER NOT NULL DEFAULT 0,  -- added in migration v2
    expiration_timer  INTEGER NOT NULL DEFAULT 0,  -- disappearing msg seconds (v7)
    accepted          INTEGER NOT NULL DEFAULT 1,  -- message request state (v8)
    blocked           INTEGER NOT NULL DEFAULT 0,  -- blocked state (v9)
    mute_expires_at   TEXT,                        -- timed mute expiry, RFC 3339 (v14)
    archived          INTEGER NOT NULL DEFAULT 0   -- hidden from sidebar (v15)
);

The id is a phone number (E.164 format) for 1:1 conversations or a base64-encoded group ID for groups.

messages

All messages, ordered by insertion rowid.

CREATE TABLE messages (
    rowid           INTEGER PRIMARY KEY AUTOINCREMENT,
    conversation_id TEXT NOT NULL REFERENCES conversations(id),
    sender          TEXT NOT NULL,       -- sender display name or empty for system
    timestamp       TEXT NOT NULL,       -- RFC 3339 timestamp
    body            TEXT NOT NULL,       -- message text
    is_system       INTEGER NOT NULL DEFAULT 0,
    status          INTEGER NOT NULL DEFAULT 0,    -- MessageStatus enum (v3)
    timestamp_ms    INTEGER NOT NULL DEFAULT 0,    -- server epoch ms (v3)
    is_edited       INTEGER NOT NULL DEFAULT 0,    -- edited flag (v6)
    is_deleted      INTEGER NOT NULL DEFAULT 0,    -- deleted flag (v6)
    quote_author    TEXT,                           -- quoted reply author (v6)
    quote_body      TEXT,                           -- quoted reply body (v6)
    quote_ts_ms     INTEGER,                        -- quoted reply timestamp (v6)
    sender_id            TEXT NOT NULL DEFAULT '',       -- sender phone number (v6)
    expires_in_seconds   INTEGER NOT NULL DEFAULT 0,    -- disappearing timer (v7)
    expiration_start_ms  INTEGER NOT NULL DEFAULT 0,    -- timer start epoch ms (v7)
    pinned               INTEGER NOT NULL DEFAULT 0,    -- pinned flag (v10)
    poll_data            TEXT,                           -- serialized poll JSON (v11)
    link_preview         TEXT,                           -- serialized preview JSON (v12)
    body_raw             TEXT,                           -- body with mention placeholders (v13)
    mentions_json        TEXT,                           -- serialized mention ranges (v13)
    entry_seq            INTEGER NOT NULL DEFAULT 0     -- row number within one message (v16)
);

CREATE INDEX idx_messages_conv_ts ON messages(conversation_id, timestamp);
CREATE INDEX idx_messages_conv_ts_ms ON messages(conversation_id, timestamp_ms);
CREATE UNIQUE INDEX idx_messages_incoming_dedup                    -- replay dedup (v16)
    ON messages(conversation_id, sender_id, timestamp_ms, entry_seq)
    WHERE sender <> 'you' AND sender_id <> '';

One incoming message persists as several rows (the body plus one row per attachment) sharing (conversation_id, sender_id, timestamp_ms); entry_seq numbers them in insertion order. The partial unique index makes replayed envelopes (reconnect redelivery) conflict-skip instead of duplicating, while outgoing rows stay outside it because the send-confirm flow rewrites their timestamp_ms.

System messages (is_system = 1) are used for join/leave notifications and are excluded from unread counts.

reactions

Emoji reactions on messages. One reaction per sender per message, with the latest emoji replacing any previous one.

CREATE TABLE reactions (
    rowid           INTEGER PRIMARY KEY AUTOINCREMENT,
    conversation_id TEXT NOT NULL,
    target_ts_ms    INTEGER NOT NULL,     -- timestamp of the reacted-to message
    target_author   TEXT NOT NULL,         -- author of the reacted-to message
    emoji           TEXT NOT NULL,
    sender          TEXT NOT NULL,         -- who sent this reaction
    UNIQUE(conversation_id, target_ts_ms, target_author, sender)
);

CREATE INDEX idx_reactions_target ON reactions(conversation_id, target_ts_ms);

poll_votes

Votes on poll messages, one row per voter per poll (v11).

CREATE TABLE poll_votes (
    conv_id        TEXT NOT NULL,
    poll_timestamp INTEGER NOT NULL,
    voter          TEXT NOT NULL,
    voter_name     TEXT,
    option_indexes TEXT NOT NULL,
    vote_count     INTEGER NOT NULL DEFAULT 1,
    UNIQUE(conv_id, poll_timestamp, voter)
);

read_markers

Tracks the last-read message per conversation for unread counting.

CREATE TABLE read_markers (
    conversation_id TEXT PRIMARY KEY REFERENCES conversations(id),
    last_read_rowid INTEGER NOT NULL DEFAULT 0
);

Unread count = incoming messages with rowid > last_read_rowid, excluding system messages and your own sent messages. /unread moves the marker back so the newest incoming message counts as unread again.

Migrations

Migrations are version-based and run sequentially in Database::migrate():

VersionChanges
1Initial schema: conversations, messages, read_markers tables
2Add muted column to conversations
3Add status and timestamp_ms columns to messages (delivery status tracking)
4Create reactions table with unique constraint per sender per message
5Add index on messages(conversation_id, timestamp_ms) for search performance
6Add is_edited, is_deleted, quote_author, quote_body, quote_ts_ms, sender_id columns to messages
7Add expiration_timer to conversations and expires_in_seconds, expiration_start_ms to messages
8Add accepted column to conversations (message request tracking)
9Add blocked column to conversations (block/unblock state)
10Add pinned column to messages (pinned messages)
11Add poll_data column to messages and create poll_votes table (polls)
12Add link_preview column to messages (link preview persistence)
13Add body_raw and mentions_json columns to messages (mention re-resolution)
14Add mute_expires_at column to conversations (timed mutes)
15Add archived column to conversations (archive)
16Add entry_seq column to messages (backfilled per message key) and a partial unique index on incoming rows (conversation_id, sender_id, timestamp_ms, entry_seq) for replay deduplication

Each migration is wrapped in a transaction. The schema_version table tracks the current version.

WAL mode

WAL mode is enabled on every connection:

PRAGMA journal_mode=WAL;
PRAGMA foreign_keys=ON;

WAL allows concurrent readers while a writer is active, preventing database locks during normal operation.

In-memory mode

When running with --incognito, Database::open_in_memory() is used instead of Database::open(). The same schema and migrations apply, but everything lives in memory and is lost on exit.

signal-cli Protocol

siggy communicates with signal-cli using JSON-RPC 2.0 over stdin/stdout. signal-cli is spawned as a child process in jsonRpc mode.

Starting signal-cli

signal-cli is launched with:

signal-cli -a +15551234567 jsonRpc

This starts signal-cli in JSON-RPC mode, reading requests from stdin and writing responses/notifications to stdout. Each message is a single JSON line.

Request format

Requests sent from siggy to signal-cli:

{
    "jsonrpc": "2.0",
    "id": "550e8400-e29b-41d4-a716-446655440000",
    "method": "send",
    "params": {
        "recipient": ["+15551234567"],
        "message": "Hello!"
    }
}

Each request has a unique UUID id for response correlation.

Response format

Responses from signal-cli for RPC calls:

{
    "jsonrpc": "2.0",
    "id": "550e8400-e29b-41d4-a716-446655440000",
    "result": { ... }
}

Or on error:

{
    "jsonrpc": "2.0",
    "id": "550e8400-e29b-41d4-a716-446655440000",
    "error": {
        "code": -1,
        "message": "error description"
    }
}

Notification format

Notifications are unsolicited JSON-RPC requests from signal-cli (no matching outbound request). They have a method field but no id:

{
    "jsonrpc": "2.0",
    "method": "receive",
    "params": {
        "envelope": {
            "source": "+15559876543",
            "sourceDevice": 1,
            "timestamp": 1700000000000,
            "dataMessage": {
                "message": "Hey there!",
                "timestamp": 1700000000000
            }
        }
    }
}

Methods used

Outbound (siggy -> signal-cli)

MethodPurpose
sendSend a message (also used for edits via editTimestamp param)
listContactsRequest the contact address book
listGroupsRequest the list of groups
sendSyncRequestRequest a sync from the primary device
sendReactionSend an emoji reaction to a message
remoteDeleteDelete a message for all recipients
sendTypingIndicatorSend typing started/stopped indicator
sendReceiptSend a read receipt for one or more messages
updateGroupCreate/rename group, add/remove members
quitGroupLeave a group
blockBlock a contact or group
unblockUnblock a contact or group
setExpirationSet disappearing message timer
updateProfileUpdate own Signal profile (name, about, emoji)
listIdentitiesList known identity keys for contacts
trustTrust a contact’s identity key
sendMessageRequestResponseAccept or delete a message request

Inbound notifications (signal-cli -> siggy)

MethodPurposeMaps to
receiveIncoming messageSignalEvent::MessageReceived
receiveTypingTyping indicatorSignalEvent::TypingIndicator
receiveReceiptDelivery/read receiptSignalEvent::ReceiptReceived

Incoming receive envelopes may also contain:

Envelope fieldPurposeMaps to
dataMessage.reactionIncoming reactionSignalEvent::ReactionReceived
dataMessage.remoteDeleteRemote delete requestSignalEvent::RemoteDeleteReceived
dataMessage.quoteQuoted reply metadataquote field on SignalMessage
editMessageEdited messageSignalEvent::EditReceived
syncMessage.sentMessageOutgoing sync (own messages from other devices)Same as above, with is_outgoing = true
syncMessage.readMessagesRead sync from other devicesSignalEvent::ReadSyncReceived
dataMessage.stickerSticker messageBody set to [Sticker: emoji]
dataMessage.textStyles / bodyRangesText formatting (bold, italic, etc.)text_styles field on SignalMessage
dataMessage.expiresInSecondsDisappearing message timerexpires_in_seconds on SignalMessage
dataMessage.isViewOnceView-once message flagBody set to [View-once message]
callMessageMissed call notificationSignalEvent::SystemMessage

Parsing logic

The stdout reader in SignalClient determines the message type by checking which fields are present:

  1. If method is present -> it’s a notification, parse based on method name
  2. If id and result/error are present -> it’s a response, look up the method via pending_requests[id] and parse accordingly
  3. Unknown methods are logged and discarded

Sync messages

Messages sent from the primary device arrive as sync messages. They are identified by having is_outgoing = true in the parsed SignalMessage. The destination field indicates the recipient, and the message is routed to the appropriate conversation.

Testing

Running tests

Run the full test suite:

cargo test

Run tests for a specific module:

cargo test app::tests          # App module tests
cargo test signal::client::tests  # Signal client tests
cargo test db::tests           # Database tests
cargo test input::tests        # Input parsing tests

Run a single test by name:

cargo test test_name

rstest

Tests use rstest for fixtures and parameterization. The crate is declared in [dev-dependencies].

Fixtures

Two #[fixture] functions provide pre-built test objects:

  • app() in app_tests.rs – returns an App with an in-memory DB and connected state.
  • db() in db.rs – returns an in-memory Database.

To use a fixture, mark the test #[rstest] and add the fixture as a parameter:

#![allow(unused)]
fn main() {
#[rstest]
fn my_test(mut app: App) {
    app.input.buffer = "/quit".to_string();
    // ...
}
}

Parameterized tests

When multiple tests share the same assertion logic but differ in inputs, use #[case] to collapse them into a single function:

#![allow(unused)]
fn main() {
#[rstest]
#[case("/quit", InputAction::Quit)]
#[case("/q",    InputAction::Quit)]
#[case("/help", InputAction::Help)]
fn command_returns_expected_action(#[case] input: &str, #[case] expected: InputAction) {
    assert_eq!(parse_input(input), expected);
}
}

Each #[case] produces a separate entry in cargo test output, so individual failures are easy to identify.

When to use what

SituationApproach
Test needs an App or DatabaseUse the fixture (#[rstest] + parameter)
3+ tests with identical structure, different dataParameterize with #[case]
2 tests with significantly different setupKeep them separate
Test doesn’t need a fixturePlain #[test] is fine

Best practices

  • Prefer #[case] over copy-paste. If you’re writing a new test and an existing parameterized test covers the same assertion pattern, add a #[case] instead of a new function.
  • Keep case data simple. Strings, numbers, and booleans work well in #[case] attributes. For complex types, build them inside the test body using a label parameter and match.
  • Add a _label parameter when case data alone doesn’t make the purpose obvious. This shows up in test names (e.g., my_test::case_basic).
  • Don’t over-parameterize. If merging tests requires a match with completely different setup logic per arm, separate tests are clearer.

Snapshot tests (insta)

Integration snapshot tests use insta with ratatui’s TestBackend to render the full UI and compare against committed .snap files. This catches layout regressions, missing overlays, and rendering bugs automatically.

Running snapshot tests

cargo test snapshot_tests

Accepting new snapshots

When you change the UI, snapshot tests will fail with a diff. Use cargo-insta to review and accept:

cargo install cargo-insta  # first time only
cargo insta accept

Accepted snapshots are committed as .snap files in src/snapshots/.

Test helpers

The snapshot test module (ui::snapshot_tests) provides:

  • demo_app() – creates an App with in-memory DB, connected state, and deterministic demo data (fixed date for stable timestamps)
  • render_to_string(app, width, height) – renders via TestBackend and returns the buffer as a trimmed string

Coverage

Snapshot tests cover:

  • Sidebar layout and conversation list
  • Chat messages (quotes, link previews, edited messages, reactions)
  • Normal vs Insert mode indicator
  • Help, settings, and about overlays
  • Narrow terminal (sidebar auto-hide)
  • Styled text (bold, monospace)
  • Polls, pinned messages, unread markers
  • Empty conversations, message requests, disappearing messages
  • Sidebar filter

Test modules

Tests are defined as #[cfg(test)] mod tests blocks within each source file.

db.rs tests

Database tests use Database::open_in_memory() for isolated, fast test instances. Coverage includes:

  • Schema migration and table creation
  • Conversation upsert and loading
  • Name updates on conflict
  • Message insertion and retrieval (ordering)
  • Unread count with read markers
  • System message exclusion from unread counts
  • Conversation ordering by most recent message
  • Mute flag round-trip
  • Last message rowid tracking

input.rs tests

Input parser tests cover:

  • Plain text passthrough
  • Empty and whitespace-only input
  • All commands and their aliases (/join, /j, /part, /p, etc.)
  • Commands with and without arguments
  • Unknown command handling

app_tests.rs tests

Application state tests (split out of app.rs) cover signal event handling, conversation management, and mode transitions.

signal/client.rs tests

Signal client tests cover JSON-RPC parsing and event routing.

Demo mode for manual testing

cargo run -- --demo

Demo mode populates the UI with dummy conversations and messages without requiring signal-cli. This is the easiest way to manually test UI changes, keybindings, and rendering.

Linting

The project enforces zero clippy warnings:

cargo clippy --tests -- -D warnings

CI runs this on every push and pull request. Fix all warnings before pushing.

Fuzz testing

The fuzz/ directory contains cargo-fuzz harnesses for external input boundaries. Fuzz testing requires nightly Rust and Linux or macOS (libfuzzer does not support Windows).

cargo install cargo-fuzz
cargo +nightly fuzz run <target>

Fuzz targets

TargetWhat it tests
fuzz_json_rpcJSON-RPC deserialization and parse_signal_event / parse_rpc_result
fuzz_input_editUTF-8 cursor navigation and string mutation at byte boundaries
fuzz_key_comboparse_key_combo with arbitrary strings from user TOML files
fuzz_command_parseparse_input with arbitrary slash commands

Run cargo fuzz list to see all available targets. Any panic found by the fuzzer is a bug to fix.

Contributing

Getting started

  1. Fork the repository and clone your fork
  2. Install prerequisites: Rust 1.70+ and signal-cli
  3. Build and run tests:
cargo build
cargo test

Use --demo mode to test the UI without a Signal account:

cargo run -- --demo

Building the native backend (experimental)

The in-progress native engine (#642) compiles behind a non-default Cargo feature and has extra prerequisites:

  • protoc (the protobuf compiler) on your PATH; the presage dependency tree compiles .proto files at build time
  • Network access to git dependencies on first build (presage is pinned to a git revision, not a crates.io release)
cargo build --no-default-features --features native-backend

Note that --all-features fails by design: the two backend features are mutually exclusive, one engine per binary. The native build currently produces a skeleton (no linking, receive, or send yet) and is not for daily use.

Making changes

  1. Create a feature branch from master:
git checkout -b feature/my-change
  1. Make your changes. Run checks before committing:
cargo clippy --tests -- -D warnings
cargo test
  1. Push your branch and open a pull request against master.

Branch naming

Use prefixed names:

PrefixUse case
feature/New functionality
fix/Bug fixes
refactor/Code restructuring
docs/Documentation changes

Examples: feature/dark-mode, fix/unread-count, docs/update-readme

Code style

  • Follow existing patterns in the codebase
  • Run cargo clippy with warnings-as-errors – CI enforces this
  • Keep commits focused: one logical change per commit
  • Write descriptive commit messages
  • Reference issue numbers in commits and PRs (e.g., closes #29)

Pull requests

  • Create a PR targeting master
  • Include a clear description of what changed and why
  • Reference the issue being addressed if applicable
  • Make sure CI passes (clippy + tests)
  • Trivial docs-only changes may be committed directly to master; all code changes must go through a PR

Reporting bugs

Use the bug report template. Include:

  • Your OS and terminal emulator
  • siggy version (siggy --version or the release tag)
  • Steps to reproduce the issue

Suggesting features

Use the feature request template. Describe the problem you’re trying to solve before proposing a solution.

License

By contributing, you agree that your contributions will be licensed under AGPL-3.0.

CI & Releases

Continuous integration

CI runs automatically on every push and pull request via .github/workflows/ci.yml.

CI pipeline

StepCommand
Checkoutactions/checkout@v4
Rust toolchaindtolnay/rust-toolchain@stable
CacheSwatinem/rust-cache@v2
Lintcargo clippy --tests -- -D warnings
Testcargo test

CI must pass before merging any PR.

Release pipeline

Releases are triggered by pushing a version tag. The workflow is defined in .github/workflows/release.yml.

Triggering a release

# 1. Update version in Cargo.toml
# 2. Commit the version bump
# 3. Tag and push
git tag v0.3.0
git push origin v0.3.0

Release pipeline steps

  1. Lint & Test – same as CI (clippy + tests)
  2. Build – compiles release binaries for 4 targets:
TargetRunnerArchive
x86_64-unknown-linux-gnuubuntu-latest.tar.gz
x86_64-apple-darwinmacos-latest.tar.gz
aarch64-apple-darwinmacos-latest.tar.gz
x86_64-pc-windows-msvcwindows-latest.zip
  1. Package – creates archives (tar.gz on Unix, zip on Windows)
  2. Release – creates a GitHub Release with auto-generated changelog and attached archives (via softprops/action-gh-release@v2)

Version tags

Use semantic versioning: v0.1.0, v0.2.0, v1.0.0.

Remember to update the version field in Cargo.toml before creating the tag.

Install scripts

Two install scripts are provided in the repository root:

install.sh (Linux / macOS)

curl -fsSL https://raw.githubusercontent.com/johnsideserf/siggy/master/install.sh | bash

Downloads the latest release binary for the detected platform and checks for signal-cli.

install.ps1 (Windows)

irm https://raw.githubusercontent.com/johnsideserf/siggy/master/install.ps1 | iex

Downloads the latest Windows release binary and checks for signal-cli.

Documentation deployment

Documentation is built and deployed via .github/workflows/docs.yml. See the docs workflow for details on how changes to the docs/ directory trigger a rebuild and deployment to GitHub Pages.

Roadmap

Completed

  • Send and receive plain text messages (1:1 and group)

  • Receive file attachments (displayed as [attachment: filename])

  • Typing indicators (receive and send)

  • SQLite-backed message persistence with WAL mode

  • Unread message counts with persistent read markers

  • Vim-style modal editing (Normal / Insert modes)

  • Responsive layout with auto-hiding sidebar

  • First-run setup wizard with QR device linking

  • TUI error screens instead of stderr crashes

  • Commands: /join, /part, /quit, /sidebar, /help

  • Load contacts and groups on startup (name resolution, groups in sidebar)

  • Echo outgoing messages from other devices via sync messages

  • Contact name resolution from address book

  • Sync request at startup to refresh data from primary device

  • Inline image preview for attachments (halfblock rendering)

  • New message notifications (terminal bell, per-type toggles, per-chat mute)

  • Command autocomplete with Tab completion

  • Settings overlay

  • Input history (Up/Down to recall previous messages)

  • Incognito mode (--incognito)

  • Demo mode (--demo)

  • Delivery/read receipt display (status symbols on outgoing messages)

  • Contact list overlay (/contacts)

  • Copy to clipboard (y/Y in Normal mode)

  • Full timestamp on scroll (status bar shows date+time of focused message)

  • Message reactions (emoji picker, badge display, full lifecycle with DB persistence)

  • @mention autocomplete (type @ in group or 1:1 chats)

  • Visible message selection (focus highlight, J/K message-level navigation)

  • Startup error handling (signal-cli stderr captured in TUI error screen)

  • Reply to specific messages (quote reply with q key)

  • Edit own messages (e key, “(edited)” label, cross-device sync)

  • Delete messages (d key, remote delete + local delete)

  • Message search (/search, n/N navigation)

  • Send file attachments (/attach command with file browser)

  • /join autocomplete (contacts and groups with Tab completion)

  • Send typing indicators (auto-start/stop on keypress)

  • Send read receipts (automatic on conversation view, configurable)

  • System messages (missed calls, safety number changes, group updates, expiration timer)

  • Message action menu (Enter in Normal mode, contextual actions on focused message)

  • Text styling (bold, italic, strikethrough, monospace, spoiler rendering)

  • Display stickers (shown as [Sticker: emoji] in chat)

  • View-once messages (shown as [View-once message] placeholder)

  • Cross-device read sync (sync read state across linked devices)

  • Disappearing messages (honor timers, countdown display, /disappearing command)

  • Group management (/group command: view/add/remove members, rename, create, leave)

  • Message requests (detect unknown senders, accept/delete with banner UI)

  • Block/unblock contacts (/block, /unblock commands)

  • Mouse support (click sidebar, scroll messages, click input bar, overlay scroll)

  • Color themes (selectable themes via /theme or /settings)

  • Desktop notifications (OS-native via notify-rust, configurable toggle)

  • Link previews (URL preview cards with title, description, thumbnail)

  • Polls (create with /poll, vote overlay, inline bar charts)

  • Pinned messages (pin/unpin with p, duration picker, banner display)

  • Identity key verification (/verify overlay with trust management)

  • Profile editor (/profile overlay for Signal profile fields)

  • About overlay (/about command showing app info)

  • Sidebar position setting (left or right placement)

  • Publish to crates.io (cargo install siggy)

  • Rename to siggy (auto-migration from signal-tui paths)

  • Forward messages (f key, filterable picker overlay)

  • Scroll position memory per conversation

  • Multi-line message input (Alt+Enter / Shift+Enter for newlines)

  • Message history pagination (scroll-up to load older messages)

  • Configurable keybindings (profiles, in-app rebinding, TOML overrides)

  • Export chat history (/export to plain text, Markdown, or JSON)

  • Sidebar filter (type-to-filter from Normal mode)

  • Jump to quoted message (Q to jump, Ctrl+O to jump back)

  • Delete conversations (/delete removes locally + declines message requests)

  • Session lock + boss key (Ctrl-L, /lock, /lock-reset, --reset-lock)

  • Native inline images inside tmux (DCS passthrough wrapping, SIGGY_IMAGE_PROTOCOL override) – thanks @cultlead3r

  • Sixel image protocol (Windows Terminal and other Sixel terminals)

  • Open attachments and URLs from the action menu

  • Multiple concurrent typing indicators per group

  • Enhanced emoji shortcode coverage – thanks @shwoop

  • Custom themes (drop-in TOML files + shipped template)

  • Auto-lock idle timer (lock_timeout config)

  • Bindable command actions (open_contacts, open_settings, open_help, toggle_sidebar, attach)

  • Non-interactive CLI for scripting (--version, --check, --send, --list, --receive)

  • Claude Code skill for the siggy CLI

  • Scheduled messages via the OS scheduler (documented pattern)

  • Side-by-side accounts via db_path config override

  • Supervised signal-cli reconnect with backoff

  • README translations (11 languages)

  • Voice message playback (inline via a detected CLI player, audio_player override)

  • Voice message duration + live playback progress (o toggles stop)

  • Compose text formatting (*bold*, _italic_, ~strike~, `mono`, ||spoiler||)

  • Spoiler reveal on focus (J/K unmasks while focused)

  • Archive conversations + mark-as-unread (/archive, /unread)

  • Fuzzy command palette (Ctrl+P over conversations and commands)

  • Outgoing link previews (/preview <url>, explicit fetch only)

  • Inline sticker rendering (cached packs via the image pipeline)

  • Sixel image tuning (sizing config, palette/dithering options, guard row) – thanks @justinledwards

  • Scriptable message triggers (triggers.toml, /triggers, headless --watch mode)

  • Signal username support (@handle contacts, /join @name.123, --send @name.123)

Future

Tracked in GitHub issues:

Have an idea? Open an issue.