Keyboard-first terminal client for iMessage, Slack and Telegram in one chat list. Split panes, realtime updates, Slack threads in their own pane, and a layout that survives restarts. Go + Bubble Tea.
SocialGo · PythonGitHub @oovetsOnlineOwner unconfirmed
Have you tried it? One tap is enough. No sign-in needed.
No updates detected yet. GitHub releases will appear here.
Owners of public repositories under a personal GitHub account can sign in to verify ownership and manage the introduction themselves. No administrator approval is needed.
Sign in, generate a verification file, and commit it to this repository’s default branch within 30 minutes. Files on other branches or forks do not count. Then check the file below to manage the project without administrator approval. Keep the file to retain your access.
If needed, you will go to sign-in. After signing in, return here and press your chosen verification button again.
Run /nomorevibe verify in your project folder to verify ownership and manage the introduction yourself.
Verify in one minuteAsk about other verification options
The maker's introduction consists of the line above. Read the repository README for more details. Repository README ↗
Loading the full README.
Read the full README ↗keyboard-first terminal client for imessage, slack and telegram, in one chat list.
imessage goes through a bluebubbles-compatible server; slack and telegram talk to their own servers directly. all of them render the same way, and which backend owns a conversation is never something you have to think about.
== contents ==
quick start get it running
configuration settings, env vars, theme
slack tokens, what works, what does not
telegram logging in, what works, what does not
using it keys, composer commands, chat list
behaviour link previews, emoticons, chat management
development layout, checks, troubleshooting
== quick start ==
requirements
go 1.25 or newer
for imessage: a bluebubbles-compatible server, over http and websocket
slack tokens, if you want slack -- optional, see below
a telegram login, if you want telegram -- optional, see below
every network is optional; the app needs one. a bluebubbles server that
is configured but unreachable at startup is reported in the status line
and left out until the next start, so it never keeps slack or telegram
from loading. one that answers but does not hand over its chat list in
time (15 seconds) is asked again after 5s, 15s, 30s, a minute, then every
two minutes; the status line says so, and its panes keep their chats from
the cache until the list arrives.
with nothing configured at all, the app says so on stderr and exits.
build and run
go build -o imessage-tui .
export BB_SERVER_URL=https://your-server:1234
export BB_PASSWORD=your-api-password
./imessage-tui
the tui owns the terminal, so nothing is ever printed to stdout.
runtime logs go to ~/.imessage-tui.log -- read that first when something
looks wrong.
== features ==
chat
imessage, slack and telegram in one list, searchable, with unread markers
optimistic sending: your message appears at once and reconciles later
tapbacks, slack and telegram reactions render as compact emoji on the
message
thread replies, quoted replies, image attachments, link previews
slack threads inline in the channel, or in a pane of their own with /t
urls open with a click on the url itself, or with /open
layout
up to 8 panes, split horizontally or vertically, each with its own chat
up to 9 tabs, each its own set of panes -- slack in one, imessage in
another
per-pane focus, resizable dividers, resizable chat list
layout, ui state and message cache survive a restart
reading
sender names get a stable colour per person, so a busy channel can be
read by who is talking. shown in group chats and hidden in one-to-one
chats by default; ctrl+b flips a single chat and is remembered
optional timestamps, line numbers, sender names, pane dividers, previews
connection
realtime over websocket (imessage), socket mode (slack) and mtproto
updates with gap recovery (telegram)
api polling reconciles anything the realtime feed missed
a backend that is down costs only itself
== configuration ==
read from env vars and ~/.config/imessage-tui/imessage.yaml; env wins.
credentials prefer the os keyring and fall back to the config file.
ui state, layout, message cache and per-chat overrides -- aliases, pins,
mutes, ignores, sender-name choices -- live in ~/.config/imessage-tui/
setting env var default what it does
----------------------------------------------------------------------------
server_url BB_SERVER_URL unset bluebubbles url
password BB_PASSWORD unset api password
theme BB_THEME auto auto|light|dark
notifications BB_NOTIFICATIONS off off|bell|desktop
message_limit BB_MESSAGE_LIMIT 50 messages per chat
chat_limit BB_CHAT_LIMIT 50 imessage chats listed
poll_interval_sec BB_POLL_INTERVAL_SEC 10 refresh; 0 disables
enable_link_previews BB_ENABLE_LINK_PREVIEWS true preview metadata
max_previews_per_message BB_MAX_PREVIEWS_PER_MESSAGE 2 previews per message
preview_proxy_url BB_PREVIEW_PROXY_URL empty optional json proxy
oembed_endpoint BB_OEMBED_ENDPOINT noembed oembed endpoint
env only
----------------------------------------------------------------------------
BB_INSECURE_TLS unset skip tls verification (self-signed; insecure)
BB_SLACK_TOKEN unset slack user token, one workspace
BB_SLACK_APP_TOKEN unset slack app-level token, for realtime
BB_SLACK_WORKSPACE slack label for the env workspace
chat_limit applies to imessage and telegram (at most 100 there, one page
of dialogs). it means "the most recent N", which needs a list ordered by
recency; slack's is not, so every slack conversation is fetched rather
than an arbitrary slice of them.
for imessage it counts known senders: the N most recent chats with people,
plus up to N more from unknown senders (what messages files under Unknown
Senders -- banks, deliveries, one-time codes), which the list keeps out of
the way. chats with no messages left are not listed: that is what a
conversation deleted on the phone looks like to bluebubbles, since its
messages move to Recently Deleted but the chat itself stays.
# ~/.config/imessage-tui/imessage.yaml
server_url: "https://your-server:1234"
password: "your-api-password"
theme: auto
notifications: off
message_limit: 50
chat_limit: 50
poll_interval_sec: 10
enable_link_previews: true
max_previews_per_message: 2
== theme ==
auto keeps a compromise palette. every colour is chosen to stay readable on
light and dark alike, and message text carries no colour at all so it
inherits the terminal's own foreground.
pinning dark or light additionally tunes the palette for that background:
on dark the accent blue, timestamps, dividers and sender colours brighten,
since they no longer have to survive a white background too.
auto never picks the tuned palette. background detection queries the
terminal over stdin, multiplexers answer it themselves, and a wrong guess
paints unreadable text -- a conservative palette is the better failure.
on a black-background xterm: set theme: dark, and make sure TERM is
xterm-256color. a TERM without a colour suffix makes lipgloss strip every
colour, so the palette never gets a chance.
== notifications ==
off by default. inside the app a message always gets noticed: see
"chat list and status". this setting is for when the terminal itself is
out of sight.
bell rings the terminal bell. terminals and window managers turn
that into an urgency hint, tmux flags the window, and over ssh
it is the one that reaches the machine in front of you.
desktop a desktop notification: notify-send on linux (mako, dunst,
gnome, kde), osascript on macos.
either goes out only while the terminal window is not focused, which the
app learns by asking the terminal for focus reports. a terminal that never
sends any -- tmux needs `set -g focus-events on` -- is treated as watched,
and is alerted only for chats that are not on screen. a chat rings or
notifies once per 30 seconds however many messages it sends -- except for
a reply to one of your own messages, a slack thread on your post included
however old the post is, which always does and says "replied to you". a
message is announced once, also a slack reply that arrives in its channel
and in its open thread at the same time. muted chats announce only replies
to you; ignored chats, your own messages, and unknown senders while those
are hidden never announce anything.
== slack ==
slack conversations appear in the same chat list as imessage. nothing about
it goes in imessage.yaml: slack is on when tokens are present and off when
they are not, and the app starts either way. a workspace that fails to
connect costs that workspace only.
two tokens per workspace, because slack needs two
xoxp-... user token: the web api acts as you and sees what you see
xapp-... app-level token: opens socket mode, and nothing else
fastest way in, for one workspace
export BB_SLACK_TOKEN=xoxp-...
export BB_SLACK_APP_TOKEN=xapp-...
export BB_SLACK_WORKSPACE=acme # optional label
for several workspaces, or to keep tokens out of your shell profile, put
them in the os keyring. an existing ~/.slack_config.json is imported on
first run and the log says so -- delete that file afterwards, it holds both
tokens in plaintext.
what works
channels, private channels, dms, group dms
threads. the replies to the ten newest are shown inline in the channel,
in time order among everything else, each with the post it answers (see
"replies"). /t #N opens a thread in
a pane beside the channel -- the root, then every reply in order --
where anything you send posts in the thread. /r #N answers in a thread
from the channel itself
files, opened with /img #N or a click
reactions, mentions resolved to names, emoji shortcodes
messages from apps -- alertmanager, linear, ci bots: their attachments
and block kit layouts are drawn as text, title, body, fields and links,
each attachment with its colour bar beside it
*bold* _italic_ ~strike~ `code`, ```code blocks``` and > quotes, drawn
as styles with the marks taken out
realtime over socket mode, several workspaces at once
what does not
deleting or renaming a conversation. imessage-only; the app says so
rather than pretending
marking read without a write scope on the user token (channels:write,
groups:write, im:write, mpim:write). without it slack answers
missing_scope, the app stops asking for that workspace, and everything
else keeps working
channels you have not joined, archived channels, closed dms. the list is
your membership, the same thing slack's own sidebar shows
a thread's read state. slack's web api has no call for it, so a thread
pane marks nothing read
an app's buttons, menus and inputs, which only do something inside
slack. a button that opens a url shows as that link; the rest are left
out. tables and slack's link-unfurl cards are not drawn
ordering
imessage first, then each workspace: people and group chats before
channels, alphabetically. slack's conversation list carries no
last-activity timestamp, and getting one would cost a history call per
conversation, so recency ordering is not on offer.
with more than one workspace connected, names are prefixed with the
workspace so two "#general" stay apart -- in pane headers, toasts and
the plain recent list. the chat list leaves it out where a group header
or the network filter already names the workspace.
if conversations are missing, the log says what was fetched
grep "conversations" ~/.imessage-tui.log
== telegram ==
telegram chats appear in the same list, most recently active first, as in
telegram itself. like slack it needs nothing in imessage.yaml: it is on for
every account that is logged in.
log in once, before starting the app
imessage-tui telegram login scan the qr code: telegram on the
phone → settings → devices → link
desktop device
imessage-tui telegram login --phone a code to the telegram app or sms
instead
a two-step verification password is asked for when the account has one.
telegram wants an api id and hash of your own, from https://my.telegram.org
→ api development tools. login reads BB_TELEGRAM_API_ID and
BB_TELEGRAM_API_HASH (or TG_API_ID / TG_API_HASH, as the desktop app
does), asks for them otherwise, and stores them with the session. after
that a normal start needs no environment.
the session is the login itself -- whoever has it is you -- so it lives in
the os keyring, never in a file. it shows up in telegram's device list as
imessage-tui.
imessage-tui telegram accounts what is logged in
imessage-tui telegram logout [<id>] end the session on telegram's side
too, and forget it
what works
private chats, groups, supergroups, channels, saved messages
replies (/r #N), reactions, photos and files (/img #N), stickers as their
emoji, unread counts, marking read
realtime with gap recovery, several accounts at once
what does not
secret chats. they are end-to-end encrypted to one device and do not
exist for a second client
deleting or renaming a chat, sending files. edits made elsewhere do show
formatting: bold, italics and labelled links arrive beside the text and
are drawn plain
reactions: telegram has no ‼️ or ❓, so emphasize and question send 🔥
and 🤔. a chat can restrict reactions further; the error says so.
== keys ==
single-letter shortcuts (? q d r g G /) and the plain arrow keys only act
when you are not typing. with the composer or chat search focused they edit
text instead. anything using ctrl, alt or shift works everywhere, including
mid-message.
moving around
tab switch focus between chat list and panes
esc back to the chat list
up / down, k / j navigate chats, or scroll messages
g / G top / bottom of the chat list; in a pane, G and
end both jump to the newest message
pgup / pgdown page through the chat list, or scroll the
focused pane; shift+up / shift+down do the same
/ search the chat list: up / down pick a match,
enter opens it, esc closes the search
ctrl+k search the chat list from anywhere, mid-message
too; a hidden list shows for the search only
n / N filter the chat list to the next / previous
network: all, imessage, each slack workspace
s group the chat list by network, or back to recent
p pin or unpin the selected chat
K / J move a pinned chat up / down in Pinned
m mute or unmute the selected chat
x ignore the selected chat, or stop ignoring it
I show or hide ignored chats
U show or hide chats from unknown senders
t show or hide the time on each chat list row
enter open the selected chat, or send the composer
shift+enter newline in the composer
? / F1 help
q / ctrl+c quit
panes
ctrl+f split the focused pane left and right
ctrl+g split the focused pane top and bottom
ctrl+w close the focused pane
shift+left / right move between panes, also while writing
ctrl+up / ctrl+down move to the pane above or below
ctrl+shift+left/right resize the focused split
ctrl+left / ctrl+right resize the chat list
tabs
alt+t new tab, with one empty pane
alt+w close the tab; its chats stay in the list
alt+1 ... alt+9 go to tab N
alt+, / alt+. previous / next tab
alt+r name the tab; an empty name goes back to naming
it after its networks
alt+n go to the newest unread chat
toggles
ctrl+s chat list
ctrl+t timestamps
ctrl+n line numbers
ctrl+e pane dividers
ctrl+p chat previews in the list
ctrl+b sender names for this chat, remembered
alt+m sender names in one-to-one chats too
chat actions
d, then D delete the selected chat, esc cancels
r rename the selected chat
ctrl+d / ctrl+r the same two, from a pane
ctrl+o pin or unpin the chat in the focused pane
mouse
click to focus a pane, open an image or open a url where it is drawn,
jump to the message a reply answers by clicking its ↱ line,
double-click a chat in the list to open it in the focused pane,
click a network chip to filter the chat list, click a tab on the tab bar
to show it, click the unread badge to open the list on that chat, scroll
to move through history or the chat list,
drag the chat list edge or a pane divider to resize.
== composer commands ==
typed into the message box and sent with enter, instead of the message.
the #N is the row number shown by ctrl+n.
/r #N text reply to row N. on slack this posts into that message's
thread -- the same thread when row N is itself a reply.
on imessage it becomes a quoted reply
/t #N open the thread on row N in a pane beside this one, or
focus the pane already showing it. slack only; anything
sent in that pane posts in the thread
/img #N open the image on row N
/open open the newest link in the pane
/open #N [K] open link K on row N, counted in the order they are
drawn; K defaults to 1
/h react to the newest message with a heart
/lol react with laughing
/tu react with thumbs up
/te react with thumbs down
/!! react with emphasis
/? react with a question mark
on slack the six reactions map onto :heart: :joy: :+1: :-1: :bangbang:
:question:
== chat list and status ==
there is no persistent status bar. prompts you have to act on -- delete and
rename confirmations, errors, toasts -- appear on the bottom row while they
are live, then disappear.
unread chats are marked in the chat list and with a dot on pane headers.
a pane you are looking at clears its own marker.
a message for a chat that is nowhere on screen -- in no pane of the tab
shown, and not in the list, because it is hidden or filtered to another
network -- shows as a toast on the bottom row: chat, sender, text. with
the list hidden, the top-right corner names the newest unread chat and
counts the rest (● Family +2); clicking it opens the list on that chat.
alt+n goes to the newest unread chat from anywhere: to the pane already
showing it, in another tab if need be, or else into the focused pane.
with more than one tab open, a tab bar sits above the panes: each tab's
number, then its name -- the one given with alt+r, or else the networks
its panes show, so a tab of slack channels reads as that workspace without
being named -- and a dot while a pane in it holds a message you have not
seen. a single tab has no bar. going to a tab filters the chat list to
its network, or to all of them when its panes mix networks; an empty tab
leaves the filter as it was. panes in a background tab keep taking
realtime messages; the periodic reload covers only the tab on screen, and
a tab coming back reloads whatever went stale while it was hidden. the
8-pane limit is per tab. tabs, their names and their panes survive a
restart.
with more than one network connected, a row of chips under the title --
All, iMessage, each slack workspace -- picks which the list shows: click
one, or step through them with n and N. a chip with unread chats shows how
many. clicking a network's header in the grouped list filters to it too. s
groups the list under a header per network, imessage first, then slack
workspaces in config order; within a group a chat with a new message still
moves to the top. filter and grouping combine with / search, and both are
remembered across restarts.
ctrl+p shows the last message under each chat. a chat with nothing to show
-- every slack conversation, since slack's list carries no last message --
stays on one line rather than leaving an empty one; with previews off every
chat is one line. the choice is remembered.
p pins the selected chat to a Pinned section at the top of the list, in
both sort orders, and ctrl+o does the same from a pane. pinned chats keep
the order you give them -- new pins go last, K and J move one -- rather than
moving with each new message, and still answer to the network filter and
search. pins live in chat_overrides.json beside the local aliases.
m mutes the selected chat -- a noisy slack channel, a telegram group: it
keeps its place in the list, pinned or not, but a new message there raises
no toast, bell or desktop notification and is left out of the unread
counts, the badge and alt+n. its row is drawn grey, with the ● still
saying something came in. a reply to one of your own messages there still
notifies, the way slack and telegram announce a mention in a muted chat.
mutes are local to the app -- nothing is muted in slack or telegram
themselves -- and kept in chat_overrides.json.
x ignores the selected chat: it leaves the list, the Pinned section and
every unread count, and stays gone when new messages arrive. this is local
to the app -- nothing is deleted or muted on the phone. I shows ignored
chats in an Ignored section at the bottom, where x on one brings it back.
ignores are kept in chat_overrides.json too.
chats from unknown senders are hidden unless they have a new message, which
lists them among the others so a code that just arrived can be found. U
shows all of them in an Unknown senders section near the bottom; pin one
to keep it in view.
deleting a chat with d needs the bluebubbles private api. without it the
delete fails and the chat comes back; x is the way to get rid of it here.
== behaviour ==
emoticons
the composer rewrites text emoticons as emoji. an emoticon has to stand
as its own whitespace-delimited word, so "hej :)" converts and
"http://x" does not
<3 </3 :) :-) =) :( ;) :| :* 8) :'( \o/ convert as you type
:D :P :p :O :o xD XD :/ :\ B) o/ convert on the next space
the second group waits for a word boundary so ":Down" and "xDrive"
survive and urls keep their "://". a rewrite is skipped while the cursor
sits behind the end of the text, so editing earlier in a draft never
moves you to the end.
sender names
shown in group chats, hidden in one-to-one chats. a group is a chat whose
participants say so (imessage), or a pane where more than one other
person has written (slack channels, telegram groups). alt+m shows names
in one-to-one chats too.
ctrl+b flips the chat in the focused pane, in every pane showing it, and
is remembered in chat_overrides.json. flipping it back to what the chat
would show anyway forgets the choice, so it follows alt+m again.
replies
a reply shows what it answers on a muted line above it, with that
message's row number and sender:
↱ #12 Erik: ska vi ta lunch kl 12?
14:05 #14 Anna: ja, kör!
when the answered message is right above -- or the reply above answers
it too, as a thread reads when nobody talks in between -- that line would
only repeat it, and the reply starts with ↳ instead. a telegram reply
that quotes part of a message shows that part beside a bar, up to three
rows. clicking the line scrolls to the answered message and flashes it;
when it is not loaded, a slack reply opens its thread, and anything else
says "reply to an earlier message".
links
a url in a message opens in the browser when you click the url itself --
clicking elsewhere on the message only focuses the pane -- including a
long url broken across rows. a preview line opens from anywhere on it.
/open opens the newest link in the pane, /open #N [K] the K-th on row N.
only http and https links are ever opened.
link previews
supported media urls render a compact preview line
[YouTube] video title [Spotify] track or playlist title
[Instagram] post or reel [Aftonbladet] article title
hosts: youtube.com m.youtube.com youtu.be spotify.com open.spotify.com
instagram.com m.instagram.com aftonbladet.se expressen.se dn.se svd.se
svt.se omni.se gp.se sydsvenskan.se di.se
fetches are async: a fallback label first, then the metadata. html
metadata is preferred, so generic titles like "search" are ignored and
refetched.
emoji
shortcodes are decoded from the same table the composer autocompletes
from, generated from the dataset slack itself uses -- so :saluting_face:
arrives as an emoji rather than as text, and anything you can receive is
something you can type with ":".
workspace-custom emoji have no unicode character and stay as their name.
chat management
delete uses the bluebubbles private api and clears the local cache only
after the server confirms: d, then D, esc to cancel.
rename uses the group-rename api when available; if the server rejects
it, the alias is saved in chat_overrides.json and applied on refresh.
== development ==
layout
main.go startup: config, backends, theme, bubble tea
provider/ the seam between the ui and the chat backends
provider/imessage/ adapts api/ and ws/ to it
provider/slack/ slack web api, socket mode, mrkdwn, guids
provider/telegram/ telegram over mtproto (gotd/td), login, updates
termqr/ qr codes drawn in the terminal, for logins
emojiset/ the shared shortcode table, generated
api/ bluebubbles http client, contacts, attachments
ws/ socket.io/websocket client, reconnect + overflow
config/ config, credentials, ui/layout/cache state
models/ chat, message, attachment, link preview
tui/ models, split layout, rendering, input, persistence
conversations are addressed by guid, and which backend owns one is
derived from its prefix: unprefixed is imessage, sl: is slack, tg: is
telegram. nothing outside provider/ parses a guid.
checks
go test ./...
gofmt -l .
go vet ./...
go build ./...
the tui tests run with a throwaway HOME, so they never touch the layout,
ui state or chat overrides in your own ~/.config/imessage-tui.
docs
the root *.md files are the source of truth. scripts/docs.sh stages them
into docs/ for mkdocs, and docs/*.md is gitignored, so never edit there.
AGENTS.md has the rules that are easy to get wrong in this codebase.
SLACK.md has the slack design and its known limits; TELEGRAM.md and
SIGNAL.md do the same for telegram and for signal, which is planned.
== bluebubbles setup ==
the server must run on a mac signed into icloud with messages enabled.
grant it full disk access, accessibility and automation.
verify connectivity with the same url and password the app uses
curl -k "https://your-server/api/v1/server/info?password=YOUR_PASSWORD"
== troubleshooting ==
no colour at all
check TERM. a value without a colour suffix, such as rxvt or plain
xterm, makes lipgloss strip every escape sequence. use xterm-256color.
tls errors
verify url, certificate trust and password. certificates are verified by
default; for a self-signed server set BB_INSECURE_TLS=1, which is
insecure and env-only on purpose.
missing contact names
make sure contacts are available to bluebubbles itself.
stale chats
check websocket connectivity. polling reconciles open chats when enabled.
slack conversations missing
grep "conversations" ~/.imessage-tui.log for what was actually fetched.
0 dm usually means the token lacks im:read.
telegram missing
grep telegram ~/.imessage-tui.log. "no longer authorized" means the
session was ended from another device: log in again.
anything else
~/.imessage-tui.log has it. the tui never writes to the terminal.