A full-featured Telegram client for the terminal
- Chat Management — Private chats, groups, supergroups, channels
- Chat Folders — Numbered folder tabs in the top bar (they moved there with the TUI 2.0 frame; selection and keys are unchanged);
[/], arrows, digits1-9, or a click switch tabs (terminal-independent);Alt+H/Alt+Lwork too wherever Option-as-Meta is on; bareh/lmove between panels instead (see Keybindings below); Telegram-compatible pinned/include/exclude filter semantics - Thread Grid — Messages on a fixed time / sender / body grid rather than bubbles: one body column the whole conversation aligns to, deterministic per-sender colours, day and unread dividers, single-row reply quotes, and delivery marks read from the chat's read markers. Real ANSI-aware word wrap, measured in display cells
- Where You Are — The chat list times are relative (
2m,4h,yd,2d), because a list is read for recency. The thread header carries the chat's kind and member count, which buffer you are in, and abot/top/allmarker beside the line position, so "is there more below" is answered without comparing two numbers. The hint bar counts what there is —idx 12 msgs · 9 buffers · 37 unread— dropping each part when it would say nothing - Content Blocks — Framed, numbered code fences with diff and comment colouring and horizontal truncation (code is never re-wrapped); ruled block quotes; hanging-indent lists; metadata cards for attachments that collapse to one line on a narrow pane; spoilers drawn in their own background until
xreveals them - Reactions —
+on a message opens a one-row picker of Telegram's twelve defaults; pick with1-9/0, the arrows, orenter. It opens on the one you already left, so pressingentertakes it off. Nothing is written locally — the chips redraw from what the server says - Channel Discussions — A channel post with comments says so under it —
12 comments · t to open, amber when there is something new — andtjumps to the linked group at the post's own copy. Nothing said it before: a channel looked like a place where nothing could be said back - Pinned Messages —
ppins the selected message or unpins it, reading which from the message itself so one key does both. Silent: no "X pinned a message" line goes into the chat. The context rail lists a chat's pins - Mute — Muted chats show 🔕 and render dimmed; desktop notifications, sound, and unread emphasis are suppressed for them
- Incoming Rich Text — Bold, italic, underline, strikethrough, inline code, links, mentions and spoilers rendered from Telegram's own text entities in a semantic palette, so what you see is what was sent rather than a Markdown round-trip. Overlapping and nested spans are layered rather than replayed
- Outgoing Markdown — Opt-in Telegram-subset formatting (
**bold**,`code`, links, …) applied on send/edit/captions; off by default, see Outgoing Markdown - Image Rendering — Kitty graphics protocol, Sixel, Unicode half-block fallback with CatmullRom scaling
- Voice/Audio Playback — Play voice messages and audio inline via
mpv/ffplay - Video — Open videos in external player (
mpv/vlc/xdg-open) - File Transfer — Save with
sintostorage.download_dir(~/Downloadsby default) under the sender's own filename, never overwriting; open withEnter, progress bar during sync - Clipboard Paste —
Ctrl+Vattaches a clipboard image or file reference and sends it as an inline photo (or document, when the format can't be a photo) - Search — Search chats, messages, and the global Telegram directory; selecting a result jumps straight to that message, scrolled and centred, paging back through history if needed
- Contacts — Contact list with online status indicators
- Help Overlay —
?opens a scrollable, lazygit-style keybinding cheat sheet built from the same bindings the app dispatches on, so it can't drift out of sync - Composer Editing Modes — emacs (readline) or vi (modal, real cursor semantics) line editing, selectable or auto-detected from
$VISUAL/$EDITOR;Ctrl+Oedits the draft in a full external editor - Mode Badge — NORMAL / INSERT / COMMAND at the head of the composer row, derived from what the next key will actually do rather than from a separate flag, so it cannot contradict the keymap
- Split Compose Preview —
Ctrl+Popens a two-column view: your source with line numbers on the left, what will actually be sent on the right, rendered by the same code that draws received messages - Per-Chat Drafts — switching chats parks the draft and restores that chat’s own, reply target and staged attachment included; the chat list shows
draft: saved locallywhere the preview would be. In memory for the session, never synced to Telegram - Config Migration —
-migrate-configupgrades an existingconfig.tomlto current defaults, with a timestamped backup and a change report - Authentication — Phone/SMS code and 2FA password, plus QR login for
telegram-mcp - First-Run Wizard — Prompts for API credentials and saves config automatically
- Notifications — Posted by your terminal itself where it supports it (so they carry the terminal's name and icon, not "Script Editor", and work over ssh), falling back to
notify-send/osascript; skipped for muted chats. See Notifications - Responsive Layout — Borderless columns sized by terminal width: chat list 38 cells, thread flexing, dropping to a narrower list and then a single panel as space runs out. Every row is exactly the terminal width
- Theming — Dark and light themes with 256-color support
- Inline Images —
ui.inline_imageschooses where a photo is drawn:neverandon_open(the default) show a metadata card in the thread and the picture full-pane when you pressEnter;alwaysalso draws an eight-row preview inline. The bound is deliberate — a message whose height changes when a thumbnail lands moves the history under you mid-scroll - Media Overlay —
Enteron a photo draws it full-pane, with the protocolmedia.image_protocolresolves to (kitty, sixel, or Unicode half-blocks). Nothing is downloaded or drawn until you ask,Esccloses, and closing removes the image the terminal was holding rather than leaving it on screen - Terminal Hyperlinks —
ui.hyperlinksputs OSC 8 links on the links in a message:auto(only on terminals known to support them),never, oralways. Links are cyan and underlined either way; this adds the click - Context Rail — A thirty-cell column beside the thread with the chat's pinned messages, members, and shared files or links, chosen by chat type. Toggled with
`, defaulted byui.rail, and shown only at 118 columns or wider. Nothing is fetched until you open it, and every section says whether it is loading, empty, or unavailable rather than leaving you to guess - Persistence — Update-sequence state and the peer access-hash cache persist to a local
state.db, so updates missed while closed are gap-recovered on next start
tg │ 1:all 2:unread 3:work 4:channels 5:archive ● connected · 1 device │ 21:04
/ filter chats… 9/9 │ # infra-oncall │ group ·… buf 1 │ ln 45/45 bot
▌# infra-oncall 2m │ 20:47 nadia That is the migration
▌ nadia: rebased, CI gr… [4] │ backfill, not the rollout.
@ Nadia Feld 6m │ It drains in ~20 min.
you: pushing the tag now │ 20:52 you Confirmed from the queue
# relay-protocol 14m │ dashboard. Resuming. ✓✓
ivo: the 429 is upstr… [2] │ 4 NEW ─────────────────────────────────────────
~ wire notes 1h │ 20:58 ivo Resumed. Canary at 5%.
draft: saved locally │ 21:01 nadia ↳ ivo Resumed. Canary at 5%.
! ops-alerts muted 2h │ Rebased onto main, CI is
p95 back under 400ms (31) │ green now. 4412 ready for
@ Mira Okonkwo 4h │ the second approval.
sounds good — thurs then │ 21:02 sam Approved. Merging behind the
# design-crit yd │ flag.
you: left comments on 3 │ [🚀 4]
! tape/changelog muted yd │ ▌ 21:03 ivo Good. I will write the
v0.4.1 — keymap overhaul │ ▌ incident note either way —
@ Jonas Vik 2d │ ▌ cheap to have, expensive to
thanks, that unblocked me │ ▌ reconstruct.
│ ··· nadia is typing…
│ reply ↳ nadia: Rebased onto main, … esc to drop
j/k move g/G ends u unread │ NORMAL › i to compose · : for commands md
q quit i compose : command r reply idx 12 msgs · 9 buffers · 37 unread
That is not a mock-up. It is docs/fixtures/frame-80x24.txt
verbatim — one of six frames the renderer is asserted against cell for
cell, so the picture cannot drift from the program.
The frame is TUI 2.0: a one-row top bar with folder tabs and connection state, borderless columns divided by single-cell rules, and a context-sensitive hint bar at the foot. Every row is exactly the terminal width.
The chat list is TUI 2.0 too: two-line rows, a type sigil instead of an
avatar (@ DM, # group, ! channel, ~ saved messages), a filter header
and a contextual footer.
The thread is TUI 2.0 as well: a fixed time / sender / body grid in place
of bubbles, with a 24-cell gutter that compresses to 20 on a narrow pane, day
and unread dividers, a cursor bar marking the message the action keys act on,
and delivery marks read from the chat's own read markers rather than assumed.
Inside a message, code fences are framed and numbered, quotes get a rule,
lists get a hanging indent, attachments get a metadata card, and spoilers
stay hidden until x.
The composer carries an always-visible mode badge — NORMAL, INSERT or
COMMAND — so one glance answers whether the next letter types or navigates.
Ctrl+P expands it to a split view with your source on the left and what
will actually be sent on the right. Drafts are per chat: switching away parks
one and switching back restores it, reply target and staged attachment
included.
The context rail is the last panel: pinned messages, members and shared
files in a thirty-cell column beside the thread, on a terminal 118 columns or
wider. Backtick toggles it, ui.rail sets the default. Nothing about it is
fetched until you open it, so a chat you open with the rail closed costs
exactly what it did before the rail existed.
Enter on a photo opens it full-pane in the terminal, drawn with
whichever image protocol media.image_protocol resolves to. Nothing is
fetched or drawn until you press it, Esc puts it away, and o hands the
same file to your system viewer if the terminal draws it badly. y copies
the selected message's text, space plays a voice note, and M clears the
unread badge without moving your place in the history.
Reactions render as chips under the message, polls show their answers with scaled bars and percentages, a link preview gets a cyan rule with the host, title and two lines of description, and a voice note draws its 24-cell waveform beside its duration. What is not there is a voice note's transcript — that is a Telegram premium call this client does not make; see TUI 2.0 design.
Download the latest release for your platform from Releases — Linux, macOS, Windows, and Android/Termux (arm64). Each archive contains all three binaries (tele-tui, telegram-mcp, telegram-api) plus this README, the LICENSE, and config.example.toml, which documents every setting. Verify what you downloaded against the release's checksums.txt.
Ask a binary what it is with tele-tui -version (or version, or --version — and the same on the other two):
tele-tui v0.4.2 (a1b2c3d, go1.25, darwin/arm64)
Releases are fully automatic: every push to main bumps the patch version, tags, builds, and publishes (use #minor / #major in a commit message to bump those instead).
# Clone
git clone https://github.com/imtaqin/telegram-cli.git
cd telegram-cli
# Build & run — first run prompts for API credentials
make runPure Go, no CGO, no native dependencies — a plain go build works everywhere.
To build the release archive itself:
make dist # this machine
make dist GOOS=linux GOARCH=arm64 # somewhere else
make dist-all # every platform the release publishes
make checksums # checksums.txt over whatever is in dist/make dist is what the release workflow runs, rather than a second recipe that would have to be kept in step with it — so what you can build and open locally is the artifact people download. It stamps the version from git describe, which make version will print; override with make dist VERSION=v1.0.0-rc1.
- Go 1.23+
- mpv (optional) — for voice/audio/video playback (
sudo apt install mpv) - Telegram API credentials — from my.telegram.org/apps
go build -trimpath -ldflags="-s -w" -o tele-tui.exe .\cmd\teletuiOn first run, you'll be prompted:
╔══════════════════════════════════════════╗
║ Telegram CLI - First Run ║
╚══════════════════════════════════════════╝
Get your API credentials from:
https://my.telegram.org/apps
Enter API ID: xxxxxxx
Enter API Hash: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Enter phone number (optional): +628xxxxxxxxxx
Config saved! Starting Telegram CLI...
Motions follow vi convention: h/j/k/l, g/G jump to the ends,
Ctrl+U/Ctrl+D move a half page, / searches whatever you're looking at
and n/N step through the matches. In the chat list and chat view — the
two browsing panels, side by side — bare h/l are lazygit-style panel
movement rather than a motion: l from the chat list focuses the chat
view, h from the chat view focuses the chat list, and each is a no-op at
its own edge rather than wrapping around.
Typing is always entered on purpose — i, Tab, a focus key, or a
click on the composer — nothing is forwarded to the composer implicitly, so
no binding below costs you the ability to type that character.
Every Alt+… binding below has an alt-free alternative except Alt+J/
Alt+K (next/prev chat — use the chat list's own j/k while it's
focused) and folder cycling from any panel other than the chat list (the
alt-free [/], arrows and digits only work while the chat list has
focus). Bare h/l do not cycle folders any more — that role belongs
entirely to [/], the arrows, the digits and Alt+H/Alt+L, since bare
h/l now move between panels (above). See "macOS: Alt bindings and the
Option key" below if Alt+… bindings don't seem to work at all.
q quits from the chat list and chat view — lazygit's "q is the way out"
everywhere it can't be mistaken for typing. It asks first if the composer
holds an unsent draft or a pending attachment; Ctrl+Q still quits
unconditionally from anywhere, including the composer, and q still closes
the help overlay as before.
/ is contextual in both browsing panels now: from the chat list it opens
a live filter over the visible chats (Esc clears it, Enter keeps it
applied and leaves a /query chip showing in the tab bar); from the chat
view it's in-chat find, as it always was. Ctrl+G is the panel-independent
global search from everywhere except the composer.
Press ? any time outside the composer for a scrollable cheat sheet.
internal/app/keymap.go is where the keymap
lives in code, but it is not all one thing: four on-screen surfaces — the
help card itself, its footer line, the status bar's hint strip, and the
one-line hint at the bottom of the screen that changes with focus — are
genuinely generated, built at runtime from the same resolved bindings
app.go dispatches on, so none of them can name a key that does not
actually fire. The hand-written prose table at the top of that same file,
internal/config/config.go's KeyConfig doc comment, and the tables
below are not generated — they are maintained by hand, in three separate
places, and can each say something the code no longer does. The tables
below are the one exception with a safety net: a test
(TestReadmeKeymapMatchesHelpSections in internal/app) diffs the key
set documented here, and which section each key is filed under, against
what the help card advertises, and fails the build on a mismatch — so this
page can lag behind by at most one unrun go test, not indefinitely. It
cannot catch a wrong description of what a key does, and it does not
read keymap.go's prose table, config.go's doc comment, or
config.example.toml at all, so drift in any of those is still possible.
| Key | Action |
|---|---|
Ctrl+Q |
Quit (keys.quit, and this is its default) |
q |
Quit — chat list / chat view only (keys.quit_browsing); confirms first if the composer holds a draft or attachment |
? |
Toggle the help overlay |
Tab / Shift+Tab |
Cycle panel focus (works from the composer too) |
F1 / Alt+1 |
Focus chat list |
F2 / Alt+2 |
Focus chat view |
F3 / Alt+3 |
Focus composer |
Esc |
Close overlay, else step back |
Alt+J / Alt+K |
Next / previous chat |
Alt+L / Alt+H |
Next / previous folder |
Alt+C / F4 |
Toggle contacts overlay |
Ctrl+G |
Search all chats (not while composing) |
: |
Command palette (not while composing) |
` |
Toggle the context rail — pinned messages, members, shared files (not while composing; needs 118 columns) |
Ctrl+V |
Paste a clipboard image |
| Key | Action |
|---|---|
j / k (or ↓ / ↑) |
Next / previous chat |
l |
Focus the chat view (h here is a no-op) |
← / → |
Previous / next folder tab |
[ / ] |
Previous / next folder tab (lazygit spelling) |
1–9 |
Jump to folder N (1 = All, always present) |
| click a folder tab | Switch to it |
g / Home |
First chat |
G / End |
Last chat |
Enter |
Open the selected chat |
i |
Compose a message |
/ |
Filter this list live (Esc clears, Enter keeps it applied) |
q |
Quit — confirms first if the composer holds a draft or attachment |
| click a chat | Select it |
| wheel | Scroll |
| Key | Action |
|---|---|
j / k (or ↓ / ↑) |
Scroll down / up (plus keys.scroll_down / keys.scroll_up, if configured to something new) |
g / Home |
Top |
G / End |
Bottom |
Ctrl+D / Ctrl+U |
Page down / up |
PgDn / PgUp |
Page down / up, keeping a line of context (plus keys.page_down / keys.page_up, if configured to something new) |
h |
Focus the chat list (l here is a no-op) |
/ or Ctrl+F |
Find in this chat |
n / N |
Next / previous match |
} / { |
Move the cursor to the next / previous message. j/k scroll the buffer by lines, vi-style; these move between messages, and G hands the cursor back so it follows new arrivals again |
1–9 |
Count prefix for the motions, as in vi: 9{ moves nine messages back, 4k scrolls four times. The pending count shows in the thread header, and any non-motion key discards it |
Esc |
Close the find input while it's open; otherwise step back to the chat list (surviving find results are not cleared first) |
r / e / d |
Reply / edit / delete message (keys.reply / keys.edit_message / keys.delete_message replace these, rather than adding to them). Telegram only lets you edit your own messages; e on somebody else's says so rather than doing nothing |
Enter |
Open attachment — a photo opens full-pane in the terminal, everything else goes to the system viewer |
o |
Open attachment in the system viewer, always |
s |
Save attachment into storage.download_dir under the sender's filename (see Where files go) |
space |
Play the selected voice note or audio message |
y |
Copy the selected message's text to the system clipboard |
+ |
React to the selected message — a one-row picker; enter on the one you already left takes it off |
p |
Pin the selected message, or unpin it when it is already pinned. Silent: no "X pinned a message" line goes into the chat |
t |
Open the discussion under a channel post — jumps to the linked group at the post's own copy, where the comments hang off it |
M |
Mark this chat read without moving the scroll or the unread divider |
x |
Reveal spoilers in the selected message (press again to hide them) |
i |
Compose a message |
q |
Quit — confirms first if the composer holds a draft or attachment |
/ is contextual in both browsing panels — vi convention, "search the
buffer in front of you": from the chat view it opens in-chat find, and
from the chat list (previous table) it opens a live local filter. /
opens the global cross-chat search overlay everywhere else that isn't the
composer (which it never reaches). Ctrl+G reaches that same global
search from any panel, chat list and chat view included, without the
ambiguity / has there.
Focus stays on the composer after you send — a conversation is a run of
messages, not one — so Esc is how you leave it.
| Key | Action |
|---|---|
Enter |
Send |
Ctrl+J / Shift+Enter |
Insert a newline (Enter alone sends) |
Esc |
Cancel reply/edit/attachment first, then leave |
Ctrl+T |
Attach a file by path |
Ctrl+V |
Paste a clipboard image |
Ctrl+O |
Edit the draft in $VISUAL/$EDITOR |
Ctrl+P |
Expand the composer to the split source/preview form, and back |
Almost nothing else is claimed at app level while the composer has focus,
so neither line-editing keymap below loses a chord. The complete exception
list: Ctrl+Q (quit, or whatever keys.quit is set to),
Ctrl+V, Esc (only when there's nothing to cancel), Tab/Shift+Tab,
the panel-focus keys (Alt+1/2/3, F1-F3), Alt+J/K/H/L
(chat/folder navigation), and Alt+C/F4 (contacts) — every one of the
hardcoded spellings there is a modifier or function key no line-editing
keymap binds. keys.quit is the one field on that list a rebind can turn
into a problem: it is matched before every other check in Update,
focus included, so quit = "x" means pressing x while writing a
message quits the app instead of typing an x — see the Warning under
"Configuring keys" below. No other Ctrl+<letter> is claimed at app
level, so Ctrl+A/B/D/E/F/J/K/O/T/U/W all reach the composer.
The composer speaks either the emacs (readline) or vi line-editing
keymap, chosen by ui.compose_editing in config.toml:
compose_editing |
Behavior |
|---|---|
"emacs" |
Readline chords, always |
"vi" |
Modal vi, always |
"auto" (default) |
Inferred from $VISUAL, falling back to $EDITOR: if the editor's command name contains "vi" (vi, vim, nvim, gvim, view) → vi, else emacs. No editor set → emacs. |
Emacs mode adds, on top of the shared table above:
| Key | Action |
|---|---|
Ctrl+A / Home |
Start of line |
Ctrl+E / End |
End of line |
Ctrl+B / Ctrl+F |
Back / forward one character |
Ctrl+U / Ctrl+K |
Kill to start / end of line |
Ctrl+W |
Kill the previous word |
Ctrl+D / Delete |
Delete the character under the cursor (also live in vi mode's insert state, but not in its normal mode) |
Vi mode starts in insert mode — typing a message is the common case,
and landing in normal mode would swallow the first word. That applies
every time the composer takes focus, not just the first: i, r and e
all leave you able to type, even though a vi user leaves the composer
through normal mode. Re-entering is the only thing that resets it — focus
being re-asserted on a resize, or after an overlay closes, keeps whatever
mode you were in. Normal mode uses
real vi cursor semantics: the cursor sits on a character, never in the
gap after it, so $x deletes the line's last character rather than the
line break, and $i inserts before it rather than after:
| Key | Action |
|---|---|
Esc |
Leave insert mode; Esc again cancels reply/edit/attachment, then leaves — same as emacs's Esc, one keystroke later |
i / a / A |
Insert before / after cursor, at end of line |
o / O |
Open a line below / above and insert |
h/l/j/k |
Move by character / line (normal mode) |
w / b |
Move by word (normal mode) |
0 / Home |
Start of line (0 only in normal mode) |
$ / End |
End of line ($ only in normal mode) |
x |
Delete a character (never the line break) |
D |
Delete to end of line (never the line break) |
dd |
Delete the whole line |
The newline chord is inert in vi's normal mode (o/O open a line there
instead) and dropped from the hint line accordingly. Shift+Enter and
Ctrl+Enter only arrive from a terminal speaking the Kitty keyboard
protocol or xterm's modifyOtherKeys — the legacy encoding has no way to
put a modifier on Enter at all, which is why Ctrl+J is the primary
newline chord: every terminal can send it. Alt+Enter is also accepted,
but deliberately not primary: its legacy encoding (Esc then CR) is
byte-for-byte what "press Esc, then press Enter" produces — exactly what a
vi user types constantly to leave insert mode and send.
Ctrl+O writes the draft to a temp file and suspends the program to run
$VISUAL (falling back to $EDITOR) on it. The variable is treated as a
shell command line, so flags work (nvim -u NONE) exactly as they do for
git, and a path with spaces needs the same quoting
(EDITOR='"/path with spaces/subl" -w'). A non-zero exit (:cq, a crash)
keeps your original draft untouched; a clean exit replaces it, trims the
trailing newline editors add, and drops you back into insert mode if
you're in vi mode.
: opens a filtered list of commands from the chat list or chat view. It is
the first piece of the TUI 2.0 design to reach the client,
and it does not change anything that already worked.
: opens it (see the Global table above). Once it's up:
| Key | Action |
|---|---|
up / down |
Move the selection |
ctrl+p / ctrl+n |
Move the selection |
tab |
Complete the highlighted command |
enter |
Run the command |
esc |
Cancel without running |
ctrl+u |
Clear the query |
Every other printable key goes into the query — that's how you filter and how you type arguments.
Matching is prefix-first, then fuzzy: mkrd finds mark-read, while an
exactly-typed name always sorts to the top so Enter runs what you typed.
Navigation is the arrows, not j/k — every printable key has to reach
the query, or commands whose names contain those letters (keymap,
mark-read) could not be typed at all.
: only opens the palette where a bare letter is not text. With the composer
focused it types a colon like any other character; the sole exception is vi
editing in normal mode, where the composer is not accepting text either, so
: opens the palette there too.
Commands available now:
| Command | Action |
|---|---|
:mark-read |
Mark the open chat read, keeping your scroll position |
:search <query> |
Open the cross-chat search, pre-filled |
:keymap |
Open the help overlay |
:quit |
Quit |
An unknown command or a surplus argument reports on the composer's hint line
rather than failing silently. More commands (pin, mute, reload-config,
theme, jump) are designed but not yet registered — each needs a service
this build doesn't have, and a palette entry that can't run would be worse
than an absent one. See TODO.md.
| Key | Action |
|---|---|
Esc |
Close |
Enter |
Accept the selection — in a dialog, this means whichever button is currently highlighted, not "confirm" |
j / k (or ↓ / ↑) |
Move — a dialog's buttons also move with Tab/Left/Right, and (outside the attach-file prompt, where j/k are typed as path text instead) with j/k too |
A confirm dialog (deleting a message, quitting with an unsent draft) starts with Cancel highlighted, not Confirm, precisely because Enter fires whichever button is lit: these dialogs guard destructive or lossy actions, so a reflex Enter must not be the thing that performs one. The highlighted button is marked two ways — reversed color, and literal [ Brackets ] around its label — so it reads correctly without color, and the dialog also renders its own one-line hint (←/→ or tab: choose · enter: accept, or just enter: dismiss for a single-button alert) so the behavior is visible in the moment, not only here.
A lazygit-style scrollable cheat sheet built from the same resolved bindings as the tables above, so a rebound key is described correctly instead of drifting out of sync with what the card shows.
| Key | Action |
|---|---|
? / Esc / q |
Close |
j / k (or ↓ / ↑) |
Scroll |
PgUp / PgDn |
Page |
g / G (or Home / End) |
Top / bottom |
The card's own footer line only spells out esc / ? / q to close · j k to scroll — PgUp/PgDn and g/G work too, just not named there; this
table is the complete list. The status bar's one-line hint strip is even
more abbreviated — it has no room to be contextual about /, and names
only the shortest path back to moving: ?, the panels, the folder tabs,
find, and the way out. It leads with ?:Help precisely because it is a
pointer at the full picture rather than the full picture itself.
While the help overlay is open it owns the keyboard entirely: everything except its own close/scroll keys is swallowed rather than passed through, since the panels behind it aren't visible.
If the Telegram client dies for good (see "Troubleshooting & Diagnostics" below), the UI is replaced by an error panel and every binding above except quit becomes inert.
The default Alt+… bindings only reach the app when the terminal reports
Option as a modifier. Several don't, by default:
| Terminal | Fix |
|---|---|
| Ghostty | macos-option-as-alt = true (default false on macOS) |
| Terminal.app | Settings → Profiles → Keyboard → "Use Option as Meta key" (off by default) |
| iTerm2 | Settings → Profiles → Keys → Left/Right Option key → "Esc+" |
| kitty / WezTerm / Alacritty | Report Option as Alt by default — nothing to change |
Without that setting, macOS composes the character itself and the
terminal sends only that — Option+1 arrives as a bare "¡" with no modifier
bit, indistinguishable from someone typing "¡" outright. No amount of key
matching recovers the binding from that: it never reaches the app as
alt+1 at all, on any terminal, because the substitution happens before
the terminal builds the key event. That's why every Alt+… binding except
next/prev chat and cross-panel folder cycling has a fallback that doesn't
depend on Option being reported — see the Global table above — and why
rebinding to ctrl+… or a function key in [keys] is the fix for those
two if Option can't be made to work.
[keys] in config.toml overrides a subset of bindings; case-insensitive,
with "escape" accepted as an alias for "esc", "option"/"opt" for
"alt", and a handful of other common spellings normalized the same way.
Only some fields are actually consulted:
| Field | Default | Wired? |
|---|---|---|
quit |
ctrl+q |
yes |
quit_browsing |
q |
yes — chat list / chat view only; confirms first if the composer holds a draft or attachment |
focus_chat_list |
f1 |
yes — in addition to the hardcoded Alt+1 |
focus_chat_view |
f2 |
yes — in addition to the hardcoded Alt+2 |
focus_composer |
f3 |
yes — in addition to the hardcoded Alt+3 |
search |
/ |
yes |
global_search |
ctrl+g |
yes |
contacts |
alt+c |
yes |
contacts_alt |
f4 |
yes — the alt-free fallback for contacts |
help |
? |
yes |
next_folder |
alt+l |
yes |
prev_folder |
alt+h |
yes |
next_chat |
alt+j |
yes |
prev_chat |
alt+k |
yes |
reply |
r |
yes — a configured value replaces the built-in r in the chat view (mnemonic, not a motion) |
edit_message |
e |
yes — replaces the built-in e |
delete_message |
d |
yes — replaces the built-in d |
scroll_up |
k |
yes — an extra spelling for scroll-up in the chat view, alongside the always-live k/↑ |
scroll_down |
j |
yes — an extra spelling for scroll-down, alongside j/↓ |
page_up |
pgup |
yes — an extra spelling for page-up, alongside PgUp |
page_down |
pgdown |
yes — an extra spelling for page-down, alongside PgDn |
forward |
f |
no — parsed and saved so old config files round-trip, but not consulted anywhere; there is no forward-a-message feature to bind it to |
A configured reply/edit_message/delete_message/scroll_up/
scroll_down/page_up/page_down that collides with a key already
claimed elsewhere is refused, not silently double-bound: the built-in
letter keeps working and the configured value is simply never reached.
"Already claimed" covers keys the chat view hardcodes for itself (g/G,
Ctrl+U/Ctrl+D, n/N, Ctrl+F, Enter/o/s) as well as the whole
app-level surface these seven fields cannot see on their own — h, l,
i, Tab, Ctrl+V, Ctrl+Q, Esc, and whatever
quit/quit_browsing/help/search/global_search/contacts/
contacts_alt/the focus and next/prev chat/folder fields resolve to. That
is what stops e.g. reply = "h" from quietly stealing panel movement.
See the Keys and SetReservedKeys doc comments on
internal/ui/components/chatview.Model for the exact resolution order.
A refusal is not left invisible. -migrate-config (below) reports the
clash as a warning when it runs, comparing every [keys] field against
what the others (and the app) claim — though it only runs on demand, not
on every plain startup. And the ? help card shows the now-unreachable
action as (unbound) instead of a blank or a wrong key: seeing that on
the card means some other binding already holds the letter you wanted, so
free it up (or pick a different key) to restore the action.
Warning: wired bindings are matched before the focused panel sees the
key, so a binding here shadows that key in the chat list and chat view.
Most of them do not reach the composer — typing there is only ever
entered deliberately (see "Composer" above), and app-level dispatch
claims almost nothing while it has focus. keys.quit is the exception:
it is matched before every other check, focus included, so quit = "x"
means pressing x while writing a message quits the app instead of
typing an x. keys.quit_browsing and keys.help are correctly
composer-safe — a bare letter there really is inert while composing.
keys.contacts/keys.contacts_alt also reach the composer (gated only
on no dialog or search overlay being open, not on which panel has focus),
but their defaults (alt+c, f4) are a modifier and a function key, so
this only bites if you rebind one onto something typable. Nothing rejects
a quit rebind onto a printable character — the collision check above
only compares [keys] fields against each other and against what the app
already claims, never against "is this a character someone types."
Prefer a chord or a function key for quit especially.
config.example.toml's[keys]block lists every field in the table above at its built-in default; a test (TestExampleConfigKeysMatchDefaultsininternal/config) fails the build if the two ever drift apart.internal/config/config.go'sdefaultConfig()remains the ultimate source of truth. Have a config of your own that predates a recent field? Run-migrate-config(below) rather than hand-editing it — it fills in exactly the gaps and tells you what it added.
Ctrl+V works from any panel while a chat is open: it spools whatever image
the system clipboard holds into a temporary file, attaches it to the
composer, and sends it as an inline photo on Enter (add a caption first if
you want one). If the clipboard holds a file reference — an image copied in
Finder or a file manager — that file is attached in place instead of being
copied.
An image that's only available as TIFF (some older macOS apps offer nothing
else) is converted to PNG on macOS and still sent as a photo; on Linux/BSD,
where no such conversion happens, a TIFF-only clipboard is spooled as-is and
sent as a document instead, since Telegram's inline-photo upload rejects
TIFF. Ctrl+V (and Ctrl+T) are refused while editing a message — edits
can't carry attachments — and switching chats or chat search results discards
a pending attachment along with the rest of the draft. If sending a pasted
attachment fails, it's restored to the composer (not lost), as long as
nothing newer has taken its place in the meantime.
Spooled files are deleted once sent, discarded, or replaced. The spool directory itself is not removed on exit — Bubble Tea never waits for in-flight uploads on quit, so deleting it there would race an upload still reading from it. Instead it's swept the next time the app starts, once it's confirmed no longer in use (a process-liveness check, with a safety floor that never touches a directory younger than 48h).
Clipboard access uses the platform's own tooling: osascript (plus sips
for the TIFF fallback) on macOS, wl-paste (Wayland) or xclip (X11) on
Linux/BSD — install wl-clipboard or xclip if neither is present — and
PowerShell on Windows.
A tab bar above the chat list shows your Telegram folders, plus a
synthesized "All" tab that's always present — even for accounts with no
custom folders, and even before the folder list has loaded. See
Keybindings for how to switch tabs — several
terminal-independent ways, plus Alt+H/Alt+L where Option-as-Meta is on.
Filtering follows Telegram's own folder semantics: chats explicitly
excluded from a folder are always hidden; chats explicitly pinned or
included are always shown, bypassing both the category flags and the
mute/read excludes below; otherwise, if a folder sets any category flag
(contacts, groups, channels, bots), a chat must match at least one of them;
ExcludeMuted / ExcludeRead then drop muted/read chats from what's left.
This build's User type carries no "is a contact" field, so a folder's
Contacts and NonContacts flags can't be told apart — both are treated as
"any private, non-bot chat." This over-includes relative to real Telegram
behaviour (a contacts-only folder also shows non-contact DMs here) but never
under-includes.
Muted chats show a 🔕 marker and render dimmed — faint title, faint unread
badge — in the chat list. Desktop notifications and the notification sound
are suppressed for messages from a muted chat, and for your own outgoing
messages (which would otherwise double-notify when they arrive back as an
update from another logged-in device). The status bar's unread counter shows
the unmuted count, with the true total in parentheses when it differs, e.g.
[3 unread (7 total)].
- Word wrap — the thread body uses real ANSI-aware word wrapping to the bubble's inner width, instead of hard truncation; a single unbroken token longer than the width still hard-wraps so it can't blow past the border.
- Jump to search result — picking a chat or message search hit opens the chat scrolled to (roughly centred on) that message, paging back through a few pages of history if it isn't already loaded.
- Read receipts follow terminal focus — a message only gets marked read
while the terminal actually has focus; if you're viewing the chat but the
terminal is unfocused, the read receipt is sent when focus returns. This
needs terminal focus-reporting support — in tmux, add
set -g focus-events onto~/.tmux.conf, or receipts won't update while inside a session. - Exact scroll targeting — scrolling and jump-to-message use a cached per-message line index rather than a rough per-message jump, so the target lands on the exact line even as photo art and sender-name lookups change bubble heights after the initial render.
Config is stored at ~/.config/tele-tui/config.toml. See config.example.toml for all options:
[telegram]
api_id = 12345678
api_hash = "your_api_hash"
[ui]
theme = "dark" # "dark" or "light"
inline_images = "on_open" # "never", "on_open", "always"
rail = false # right-hand context rail (not implemented yet)
[media]
image_protocol = "auto" # "auto", "kitty", "sixel", "blocks"
voice_player = "mpv" # "mpv", "ffplay"
video_player = "mpv" # "mpv", "vlc", "xdg-open"bin/tele-tui -migrate-configBrings an existing ~/.config/tele-tui/config.toml (or wherever
TELETUI_CONFIG points) up to current defaults and exits without starting
the app. Recommended for any config that predates this branch — several
key defaults changed underneath it, and a config from before will otherwise
carry conflicting bindings silently.
What one run does:
- Retires stale key defaults. A
[keys]field holding a value that used to ship as the default — not something you chose — is replaced with the current one:focus_chat_list/focus_chat_view/focus_composerfromctrl+1/ctrl+2/ctrl+3tof1/f2/f3;contactsfromctrl+k(now the composer's kill-to-end-of-line) toalt+c;next_chat/prev_chatfromctrl+j/ctrl+k(now the newline chord and kill-to-start-of-line) toalt+j/alt+k. A binding you actually chose is left exactly alone, even if it now collides with something — see the collision report below. - Fills in fields your file never had, at their current default: any
newer
[keys]field (help,global_search,contacts_alt, …),ui.compose_editing("auto"),storage.download_dir(~/Downloads, wheressaves — see Where files go), andnotifications.method("auto", see Notifications), andstorage.state_file— written out explicitly as the path the client would otherwise derive implicitly (next tosession_file), so the location stops being implied. Alsoui.parse_markdown, but as a special case: it's set totrueon migration and the change is reported, on the reasoning that an existing user already has a working setup and the feature is worth having — brand-new configs still default tofalse(see Outgoing Markdown below). TUI 2.0 addsui.inline_images("on_open") andui.rail(false). - Names the fields this version removed, as
field old -> (removed).ui.chat_list_widthandui.show_avatarsare gone: the chat list is a fixed 38 cells because the grid inside it is measured in display cells, and avatars are an explicit TUI 2.0 non-goal — the type sigil replaced them. Reported as removals rather than as unrecognized keys, because they are different news: an unrecognized key reads as a typo you should fix, a removed one is a setting that used to work. The old values are in the backup. - Reports what it did, field by field, as
field old -> new; any config section your file didn't have at all, now added in full with defaults; any key your file had that this version no longer recognizes (dropped by the rewrite, but preserved in the backup); and any[keys]bindings that now collide with each other — checked only among the fieldsinternal/appactually dispatches on, so a value that happens to match a hardcoded binding (i, the composer's readline chords, …) isn't flagged. - Backs up first, unconditionally. The original is copied to
config.toml.bak, byte-for-byte, at0600, before anything is rewritten — it's the only copy that keeps your comments and key ordering, since the migration re-encodes the whole file through the TOML marshaller. An existing backup is never overwritten: migrating twice gets aconfig.toml.bak.<YYYYMMDD-HHMMSS>the second time, so a repeat run can't clobber your one copy of the real original. - Writes atomically, at
0600, following symlinks. Both the config and its backup land at the resolved path — aconfig.tomlsymlinked in from a dotfiles repo gets rewritten in place there, not replaced with a plain file that breaks the link. The write itself goes to a temp file in the same directory,fsync'd, then renamed over the target, so a crash mid-write can't leave a truncated config. - Keeps paths portable.
session_file/files_dir/download_dir/state_fileare written back exactly as your file had them — a~/...form stays~/...— and a path your file lacked is filled with the same portable~/...literal the built-in default uses, never an absolute path baked to one machine's home directory. - Idempotent. A config already at current defaults reports "already up to date" and writes nothing (no backup, no rewrite); running it again right after a successful migration is a no-op.
If there's no config file to migrate — including a TELETUI_CONFIG path
that doesn't exist — it says so and exits; there's nothing to back up,
since the app writes a fresh default config on first run anyway.
Off by default. Turn it on with ui.parse_markdown = true in
config.toml — or run -migrate-config (the previous section), which
turns it on for an existing config and reports the change.
| Markup | Result |
|---|---|
**text** |
bold |
__text__ |
italic |
`text` |
inline code |
| Triple backtick fence, optional language on the opening line | fenced code block — a single word right after the opening fence, followed by a newline, is read as the language and is not part of the content |
~~text~~ |
strikethrough |
||text|| |
spoiler |
[text](url) |
link — parentheses inside url may nest if balanced, and the scheme must be http, https, tg, mailto, or ftp |
Applies everywhere typed text reaches the wire: text messages, edits, and photo/file captions alike.
Why off by default: the composer has no preview, so with parsing on
silently by default the first time you'd notice is after a message has
already gone out — and the syntax overlaps with things people paste
verbatim. __init__ arrives as init; a code snippet full of ** loses
it; a table of || cells collapses into spoilers. Opting in means knowing
that transformation happens. -migrate-config turns it on for existing
configs specifically because they already have a working setup and it's
worth having — but tells you so in its report — and the shipped
config.example.toml turns it on too, with the
same warning inline.
Fallback guarantees, so a message is never mangled by a marker that wasn't meant as one:
- An opening marker with no matching closer — or an empty span like
****— is sent exactly as typed. `code`and```pre```are opaque: markers found inside them are never interpreted, which is what makes it possible to send markdown about markdown.- A
[text](url)whose scheme isn't on the allowlist (javascript:,data:, a bare/schemeless string, …) is sent exactly as typed too — not silently dropped or half-converted. The check is deliberately an allowlist rather than a blocklist: new dangerous schemes get invented faster than a blocklist can track them.
Set with notifications.method in config.toml:
| Value | Who posts it |
|---|---|
"auto" (default) |
The terminal, where it is known to understand the sequence; the system otherwise. |
"terminal" |
Always the terminal — for one the allowlist doesn't know. A terminal that doesn't understand the sequence prints it, into whatever is on screen. |
"system" |
Always the platform notifier: notify-send on Linux, osascript on macOS. |
Why the terminal, and why macOS says "Script Editor". osascript posts
notifications as Script Editor, because the process is Script Editor — so
the alert carries its name, its icon, and its notification settings. No flag
changes that, and a command-line binary can't post under its own name on
macOS at all: UserNotifications requires a bundle identifier, which
requires shipping an .app.
Your terminal already has all three, granted deliberately. Asking it to post the alert gives you the right name and icon, and works over ssh — where a system notification fires on the wrong machine.
| Terminal | Support |
|---|---|
| kitty, Ghostty, WezTerm, foot, urxvt | Title and body (OSC 777) |
| iTerm2, Windows Terminal | Body only (OSC 9) — the sender is folded into the message |
| Terminal.app | None; falls back to the system |
| Under tmux or screen | Nothing is sent — whether the sequence gets through depends on configuration that can't be read from inside |
Muted chats never notify. The mute flag is read from your account's notify settings, including for chats below the first page of the dialog list — a message from one of those holds its notification until the client has been told who the chat is, rather than ringing first and asking afterwards. If that answer doesn't arrive within a few seconds the notification goes out anyway, unnamed: a late alert beats a lost one.
If there's a gap of a few cells between the folder tabs and the clock, this
is the setting. Set ui.emoji_width = "separate" and it closes.
Emoji width is not a property of the string — the terminal decides it, and terminals disagree. Three kinds of sequence carry a composition rule, and a terminal that applies it and one that doesn't draw different widths:
| Sequence | Tables say | Composed | Not composed |
|---|---|---|---|
❤️ — a narrow base plus U+FE0F |
2 | 2 | 1 (the selector is ignored, the text heart is drawn) |
👨👩👧 — three emoji joined by U+200D |
2 | 2 | 6 (all three are drawn) |
🇷🇸 — a regional-indicator pair |
2 | 2 | 4 (two letter-boxes) |
👍🏻 — an emoji plus a skin tone |
2 | 2 | 4 (the swatch is drawn beside it) |
So "narrow or wide" is the wrong question: the same terminal is narrower than the tables on the first row and wider on the other two. The question is whether it composes, which is what the values name:
emoji_width |
Meaning |
|---|---|
"auto" (default) |
Don't assume. Measure with the tables, and where a row is being laid out against a budget, keep room for whichever rendering is wider. Never overflows; may leave a gap. |
"composed" |
This terminal applies every rule. |
"separate" |
This terminal applies none of them. |
It is a declaration because it cannot be detected: no environment variable reports it, and the runtime query that would ask was removed for leaking its response bytes into the composer. There's no harm in trying the other value — set it, look at the top bar, keep whichever ends flush against the clock.
The setting is process-wide and read once at startup, like the colour profile, so every panel that measures a string agrees about it.
Two directories under [storage], and they are not the same thing:
| Setting | Default | What it holds |
|---|---|---|
files_dir |
~/.local/share/tele-tui/files |
The media cache. Downloads land here named by their Telegram file id, so a photo drawn twice is fetched once. Nothing here is meant to be found by hand. |
download_dir |
~/Downloads |
Where s saves: a copy under the sender's own filename, in the folder you'd look in. |
s downloads into the cache (if it isn't there already) and then copies out
of it. The copy:
- never overwrites — a second
photo.jpgis saved asphoto (2).jpg, with the suffix before the extension, so it's still a.jpg; - stays in the directory — the filename comes off the wire, so it's
reduced to its last path element before use: a document called
../../.ssh/authorized_keysis saved asauthorized_keys, and a name that reduces to nothing becomestelegram-file; - leaves nothing behind if it fails — no half-written file in Downloads that looks like it worked.
If download_dir is unset, s says so rather than guessing. Running
-migrate-config fills it in with the ~/Downloads literal.
The TUI (and any process's login subcommand) persists Telegram's
update-sequence state (pts/qts/seq/date) and a peer access-hash cache to a
bbolt database, state.db, next to the session file it's using — by
default ~/.local/share/tele-tui/state.db. This is what lets updates that
arrived while the app was closed be recovered via updates.getDifference
on the next start, and avoids re-resolving every peer from scratch each
session. The peer cache is namespaced per session file (a hash of its
path), so multiple accounts sharing one state.db — e.g. telegram-api
or telegram-mcp logged in as a different account than the TUI — don't
clobber each other's access hashes. Override the path with state_file
under [storage] in config.toml.
telegram-api serve and telegram-mcp serve never open it: bbolt takes an
exclusive file lock, and both run their Telegram connection in no-updates
(RPC-only) mode specifically so they can sit alongside a possibly-running
TUI over the same data directory without contending for it. If the
database can't be opened at all (e.g. briefly locked by another process's
login), that's logged and non-fatal — the run just falls back to gotd's
in-memory state for that session, losing gap recovery but nothing else.
Logging is silenced by default, tele-tui only. The TUI owns the
terminal in raw mode, so a stray log write lands in the middle of a
rendered frame — this used to happen from background goroutines logging
"connection state: connecting" every time the network blipped.
telegram-api and telegram-mcp are unaffected and keep logging to
stderr normally.
Get the log back with TELETUI_DEBUG:
TELETUI_DEBUG=/tmp/teletui.log bin/tele-tuiThe file is opened in append mode with a
=== teletui session started <RFC3339> (pid <pid>) === banner per run,
never truncated — debugging this app usually means restarting it
repeatedly, and truncating on every start would destroy the log of the run
that actually reproduced the problem. If the path can't be opened, that's
reported once on stderr (this runs before the alt screen takes over) and
the run continues with logging disabled, rather than sitting there waiting
for output that will never arrive.
If the Telegram client dies for good — the session was revoked from
another device, or the connection failed in a way the client gave up on —
the whole UI is replaced by an error panel: what happened, a plain
statement that it will not recover on its own, and a nudge to restart.
Every keybinding except quit goes inert at that point — the panels behind
the error screen are still holding their last-known state, but acting on
them would only mutate data you can no longer see. The panel points at
TELETUI_DEBUG for more detail.
Non-fatal degradations — the client keeps running, just with something
turned off — surface as a ⚠ ... notice on the composer's hint line
instead of a full-screen panel. Two you may see in practice: the
update-state database being locked by another tele-tui/login process
(gap recovery disabled for this run only — see Persistence),
and the peer cache in state.db having belonged to a different account
(rebuilt automatically, also covered there).
The terminal-native redesign, repository reconciliation, resolved product decisions, phased delivery plan, and verification matrix are recorded in docs/tui-2.0.md. All thirteen design decisions are now closed, so the document is a contract rather than a proposal.
The frame has landed. internal/ui/layout (responsive budget),
internal/ui/frame (borderless assembly), topbar, hintbar, and the
semantic palette in internal/ui/theme now draw the screen you see in the
screenshot above: no panel borders, single-cell rules, a top bar and a hint
bar. Every row is exactly the terminal width, verified against the goldens.
Also landed, invisibly: internal/ui/cell (terminal geometry),
internal/ui/golden (the fixture harness), the interaction-mode resolver, and
the : command palette.
All four content blocks that were waiting on Telegram data — reactions, poll results, link previews and a voice note's waveform — are mapped and drawn, and the frame is now asserted against the goldens byte for byte at all six fixture sizes. A voice note's transcript stays absent: it is a premium RPC this client does not make.
Visual sign-off is settled. docs/fixtures/ holds cell-exact
golden renderings at 80×24, 100×30, 120×40, 137×29, and 200×60, plus a
CJK/emoji/RTL/ZWJ fixture and a block gallery; every line is exactly its stated
display width. They are the acceptance artifact for frame integrity and column
alignment, and every one but the block gallery is now asserted cell for cell
against a fixed scene — go test ./internal/app -run TestFrameMatchesTheGoldens,
with -update to regenerate after a deliberate copy change. The
original design handoff is archived unmodified in
docs/handoff/ — read it as history, not as instructions, since
review has since overturned several of its points.
┌──────────────────────────────────────────────────────┐
│ Bubbletea v2 │
│ ╭────────╮ ╭──────────────╮ ╭──────────────────╮ │
│ │ Chat │ │ Messages │ │ Composer │ │
│ │ List │ │ (grid) │ │ (text input) │ │
│ ╰────────╯ ╰──────────────╯ ╰──────────────────╯ │
│ ╭──────────────────────────────────────────────────╮ │
│ │ Status Bar + Help │ │
│ ╰──────────────────────────────────────────────────╯ │
├──────────────────────────────────────────────────────┤
│ Store (thread-safe cache) │
│ Chats · Messages · Users · Files │
├──────────────────────────────────────────────────────┤
│ gotd/td — pure Go MTProto client │
│ Update dispatcher → p.Send(tea.Msg) │
└──────────────────────────────────────────────────────┘
cmd/teletui/ Entry point + first-run wizard
internal/
app/ Root bubbletea model, key routing, layout
config/ TOML config loader + auto-save
telegram/ gotd/td client wrapper + domain types
types.go Domain types (Chat/Message/User/File...)
auth.go Phone/code/2FA auth flow
listener.go Update dispatcher → tea.Msg bridge
chats.go Dialog list, history, search
messages.go Send/edit/fetch messages
files.go File registry + downloader
ui/
theme/ 256-color dark/light themes
layout/ Responsive panel sizing
cell/ Terminal geometry: display-width measuring, fit/pad/wrap
golden/ Fixture harness for the docs/fixtures frame goldens
frame/ Borderless screen assembly — exact-width rows
widgets/ List, textarea, spinner, tabs, progress bar
components/
chatlist/ Chat list with avatars + unread badges
chatview/ Thread grid + media playback
composer/ Text input with reply/edit modes
auth/ Auth flow screens
search/ Tabbed search overlay
contacts/ Contact list
rail/ Context rail: pinned, members, shared files
statusbar/ Connection status + typing indicators
dialog/ Modal dialogs
palette/ `:` command palette overlay
topbar/ Top chrome row: folder tabs, connection, clock
hintbar/ Bottom chrome row: contextual hints and counters
media/ Image rendering (kitty/sixel/blocks)
render/ Message content → terminal output
notification/ Desktop notifications
store/ Thread-safe in-memory caches
make build # compile binaries → bin/tele-tui + bin/telegram-mcp (CGO_ENABLED=0)
make run # build + run
make test # run tests
make clean # remove build artifactsThe repo also ships telegram-mcp, an MCP server (stdio transport) that exposes your Telegram account to AI agents. It shares the config with the TUI but uses its own session file.
The MCP server uses a separate session (session-mcp.json), so log in once even if the TUI is already logged in:
bin/telegram-mcp login # phone → code → 2FA
bin/telegram-mcp login --qr # scan in Telegram → Settings → DevicesQR tokens refresh automatically until the login is accepted or cancelled. If
the account has two-step verification enabled, the password is read without
echoing it to the terminal. Both login modes write
~/.local/share/tele-tui/session-mcp.json by default.
Register the server in your MCP client, e.g.:
{
"mcpServers": {
"telegram": {
"command": "telegram-mcp",
"args": ["serve"]
}
}
}serve is the default subcommand; it fails fast with session not authorized, run 'telegram-mcp login' first on stderr when the session is missing or expired.
| Tool | Description |
|---|---|
get_me |
Authorized user info |
list_chats |
Dialog list (pinned first, then recent) |
get_chat_history |
Messages of a chat, newest first |
search_chats |
Search chats by title/username |
search_messages |
Global message search |
get_contacts |
Contact list |
send_message |
Send a text message (optional reply) |
send_file |
Upload a local file as a document (optional caption) |
edit_message |
Edit a message text |
mark_read |
Mark messages as read |
download_media |
Download message media, returns local path |
telegram-mcp uses its own session file (session-mcp.json) so the TUI and any number of MCP server processes each get their own Telegram connection with full realtime updates — like running Telegram on multiple devices. Set TELETUI_SESSION=/path/to/session.json to override the session path if you ever need to share one explicitly.
telegram-api is a plain HTTP/JSON companion to the MCP server — same Telegram layer, same endpoints as the MCP tools, standard library only.
bin/telegram-api login & # if not already logged in via tele-tui or telegram-mcp
bin/telegram-api serve # listens on 127.0.0.1:8080, auth token auto-generated on first runIt binds 127.0.0.1 only by default. Change the address with -addr or the TELETUI_API_ADDR env var:
bin/telegram-api serve -addr 127.0.0.1:9090
# or
TELETUI_API_ADDR=127.0.0.1:9090 bin/telegram-api servePrecedence: -addr flag > TELETUI_API_ADDR > 127.0.0.1:8080. It shares the MCP session file (session-mcp.json) — login via telegram-api login or telegram-mcp login once, both work. TELETUI_SESSION overrides the session path.
Every route except GET /api/health requires a bearer token, including
requests to 127.0.0.1 — there is no loopback exemption. The token is
resolved in this order, first non-empty wins:
-token-file <path>flagTELETUI_API_TOKENenvironment variable- a default token file next to the (already
-mcp-suffixed) session file — typically~/.local/share/tele-tui/api-token— auto-generated (32 random bytes, hex-encoded) and written with0600permissions the first timeserveruns without one
The server refuses to start if the resolved token is empty or blank — serving unauthenticated by accident isn't possible short of the explicit opt-out below. The token value itself is never logged, only where it came from.
Clients send it as Authorization: Bearer <token>; the comparison is
constant-time. A missing or wrong token gets 401.
TOKEN=$(cat ~/.local/share/tele-tui/api-token)
curl -s http://127.0.0.1:8080/api/chats?limit=10 \
-H "Authorization: Bearer $TOKEN"
curl -s -X POST http://127.0.0.1:8080/api/send \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"chat_id": 123456789, "text": "hello from the API"}'-insecure-no-auth disables the token check entirely and logs a loud
warning on startup — only use it on a fully trusted, isolated network:
bin/telegram-api serve -insecure-no-authThe server always validates the request's Host header (and Origin, if
present) against an allowlist — localhost / 127.0.0.1 / ::1 plus
whatever the listen address and -allowed-host add — before it even looks
at the auth token, so a browser-driven cross-origin or DNS-rebinding request
is rejected before it gets a chance to try one. A request with a
disallowed Host/Origin gets 403 with the current allowlist in the response
body. Binding a wildcard address (0.0.0.0, ::, or no host at all, e.g.
-addr :8080) does not auto-allow anything — exposing the server beyond
loopback needs an explicit -allowed-host:
bin/telegram-api serve -addr 0.0.0.0:8080 -allowed-host 192.168.1.50-allowed-host is repeatable and accepts a comma-separated list; 0.0.0.0
and :: are rejected even if passed explicitly (they can be tricked into
matching "localhost" by a browser — the "0.0.0.0 day" class of bug).
Every POST request must send Content-Type: application/json (parameters
are ignored, so application/json; charset=utf-8 is fine) or gets 415.
| Method | Path | Description |
|---|---|---|
| GET | /api/health |
Health check (no Telegram call, no auth required) |
| GET | /api/me |
Authorized user info |
| GET | /api/chats?limit= |
Dialog list |
| GET | /api/chats/{id}/history?limit=&from_message_id=&offset= |
Chat messages, newest first |
| GET | /api/search/chats?q=&limit= |
Search chats |
| GET | /api/search/messages?q=&limit= |
Global message search |
| GET | /api/contacts |
Contact list |
| POST | /api/send |
Send text {chat_id, text, reply_to_message_id?} |
| POST | /api/send-file |
Send file {chat_id, path, caption?, reply_to_message_id?} |
| POST | /api/edit |
Edit message {chat_id, message_id, text} |
| POST | /api/mark-read |
Mark read {chat_id, message_ids[]} |
| GET | /api/media?chat_id=&message_id= |
Download message media, returns local path |
Errors are JSON ({"error": "..."}) with status 400 (bad params), 401
(missing/invalid bearer token), 403 (forbidden Host/Origin — body includes
allowed_hosts), 404 (unknown route/chat), 415 (POST without
Content-Type: application/json), or 502 (upstream Telegram error).
- Fork the repository
- Create your feature branch (
git checkout -b feature/awesome) - Commit your changes
- Push to the branch
- Open a Pull Request
MIT License - see LICENSE for details.