An intuitive terminal UI for Microsoft Teams chats, built in Go with Bubble Tea v2. It focuses on one-to-one, group, and meeting chats, and gives you a heads-up when a meeting is about to start.
Teams — Ada Lovelace
┌──────────────────────────────┐┌────────────────────────────────────────────┐
│ Chats ││ Grace Hopper 14:02 │
│ [>] Grace Hopper ││ Did you push the nanosecond demo? │
│ Grace: did you push… ││ │
│ [#] Compiler Team ││ You 14:03 │
│ You: shipping v2 now ││ Yes — merged and shipping v2 now. │
│ [@] Standup ││ │
│ Alan: notes attached │└──────────────────────────────────────────────┘
│ │┌────────────────────────────────────────────┐
│ ││ Type a message… (enter to send) │
└──────────────────────────────┘└────────────────────────────────────────────┘
COMPOSE · 3 chats
tab next pane · enter send · ctrl+r refresh · ? help · ctrl+c quit
- Chats only, done well: one-to-one, multi-person group, and meeting chats.
- Live updates via lightweight polling of Microsoft Graph.
- Send messages inline from the compose box.
- Code blocks: incoming code blocks render as styled panels with
per-language syntax highlighting (via chroma), plus inline
`code`. Teams' own<codeblock>element and<pre>/<code>from other clients are both recognized, and the language is detected from the message (e.g. aclass="Php"block highlights as PHP). When composing, type a Markdown fence (```, optionally with a language like```go) to start a code block:enterthen adds new lines instead of sending, so you can type multiple lines, and a closing```line letsentersend as usual. Both render as real code blocks for everyone, including the native Teams client. - Emoji: incoming Teams emoji render as Unicode glyphs. When composing,
classic emoticons (
:-),<3,:D) are auto-replaced with the emoji as you type, like the desktop client. Colon-style emoticons (:),:p,:D) convert only once you type a space after them, so they don't get in the way of shortcodes that share the same start (e.g.:party). Typing:followed by at least two characters opens an inline shortcode picker (:thumbsup:) above the compose box (↑/↓to select,tab/enterto insert,escto dismiss) without interrupting your typing. The full standard emoji set is supported, and shortcodes match with or without separators —:nauseatedface:(the Teams form) and:nauseated_face:both work. Pressctrl+jto open a browser of every emoji and filter it interactively. - @-mentions: in a group or meeting chat, type
@to open a participant picker andtab/enterto complete a name (↑/↓to choose, keep typing to filter by first name or surname). The completed name is sent as a real Teams mention, so the person is notified and the mention highlights for everyone. - Spell check: as you type, misspelled words and their top corrections
appear on a strip beneath the compose box. Press
ctrl+fwhile composing to open a correction picker (↑/↓to choose aword → suggestion,enterto apply,escto cancel). It uses the system spell checker (enchant-2, the GNOME/gspell layer, falling back to hunspell), so it honours your installed dictionaries and personal word list. Configure the dictionary withspell_language(e.g.en_GB) or turn it off withdisable_spell_check; it's silently inactive if no helper is installed. - Presence: see each participant's Teams status next to their name, and set
your own status from a popup (
ctrl+s). While running, the app maintains a presence session so your chosen status persists like a native client. - Meeting alerts: an in-TUI banner and a desktop notification when a meeting is starting within your lookahead window.
- Message notifications: a desktop notification for new incoming messages
across all your chats (your own messages and the chat you're actively viewing
while the terminal is focused are skipped). Disable with
disable_desktop_notify, which also turns off meeting alerts. - Click to jump to the chat: on Linux, clicking a message notification
raises the terminal window, selects teams-tui's tmux pane (auto-detected from
$TMUX_PANE), and switches to the sending chat. Window raising uses thefocus_commandconfig (default targets GNOME's Ptyxis via its D-Bus activation, passing the notification's XDG activation token so Wayland allows the raise); set your own command for other terminals/WMs — e.g. an X11wmctrl -a/xdotoolinvocation — or"-"to disable raising while still switching the chat. Clickable notifications need a D-Bus notification daemon that advertises theactionscapability; elsewhere the notification still appears but isn't clickable. - Hybrid-friendly auth: uses the OAuth 2.0 device authorization grant, so sign-in happens in your real browser. This works for both fully Entra-hosted tenants and hybrid Entra/Active Directory federated setups — if your tenant redirects to a company-hosted web login (ADFS or similar), that page simply appears during the browser step.
- Secure token cache: refresh tokens are stored in your OS keyring (Keychain / Secret Service / Credential Manager), so you don't re-auth every launch.
- Built almost entirely from off-the-shelf
Bubbles components (
list,viewport,textarea,spinner,help,key) and Lip Gloss styling.
- Go 1.24+
- A registered Microsoft Entra application (see below)
- On Linux, a Secret Service provider (e.g. GNOME Keyring / KWallet) for the token cache
The device-code flow needs a public client application registered in your tenant. An administrator performs these one-time steps in the Entra admin center:
-
Identity → Applications → App registrations → New registration.
- Name:
teams-tui(anything). - Supported account types: Accounts in this organizational directory only (single tenant) is recommended for corporate/hybrid setups.
- Leave the Redirect URI blank.
- Name:
-
Open the new app → Authentication.
- Under Advanced settings, set Allow public client flows to Yes. (This enables the device-code grant.)
-
Open API permissions → Add a permission → Microsoft Graph → Delegated permissions and add:
User.ReadChat.ReadWritePeople.Read(to list contacts and start new chats from them)Calendars.Read(for meeting notifications; optional)Presence.Read.All(to show participants' Teams status; needs admin consent)Presence.ReadWrite(to show and set your own status)- Click Grant admin consent if required by your tenant.
Note:
openid,profile, andoffline_accessare OpenID Connect scopes, not Graph resource permissions — they won't appear in the main Graph permissions list. You do not need to add them in the portal: teams-tui requests them as scopes during sign-in and Entra grants them automatically. (If you want them listed explicitly, they live under the collapsed "OpenId permissions" group on the same Microsoft Graph → Delegated permissions screen.) -
Note the Application (client) ID and the Directory (tenant) ID from the app's Overview page.
For hybrid Entra/AD federated tenants, always use the specific tenant GUID (not
common/organizations) so the browser sign-in is routed to your federation/web login.
teams-tui reads configuration from environment variables and/or a JSON file. Environment variables take precedence.
| Variable | Description |
|---|---|
TEAMS_TUI_CLIENT_ID |
Application (client) ID. Required. |
TEAMS_TUI_TENANT_ID |
Directory (tenant) ID or GUID. Default organizations. |
TEAMS_TUI_AUTH_HOST |
Login host. Default https://login.microsoftonline.com. |
TEAMS_TUI_GRAPH_BASE_URL |
Graph base URL. Default https://graph.microsoft.com/v1.0. |
TEAMS_TUI_SCOPES |
Space-separated scope override. |
TEAMS_TUI_CONFIG |
Path to the JSON config file. |
Default location: $XDG_CONFIG_HOME/teams-tui/config.json (typically
~/.config/teams-tui/config.json).
{
"client_id": "00000000-0000-0000-0000-000000000000",
"tenant_id": "11111111-1111-1111-1111-111111111111",
"poll_interval_seconds": 10,
"max_chats": 200,
"meeting_lookahead_minutes": 5,
"disable_desktop_notify": false,
"code_block_style": "monokai",
"disable_spell_check": false,
"spell_language": "",
"focus_command": "gdbus call --session --dest org.gnome.Ptyxis --object-path /org/gnome/Ptyxis --method org.freedesktop.Application.Activate \"{'activation-token': <'{token}'>}\""
}max_chats caps how many chats are loaded into the sidebar. Graph returns them
most-recently-active first, 50 per page, and teams-tui follows the paging links
until it has this many, so raising it costs one extra request per 50 chats on
every poll. Defaults to 200; overridable via TEAMS_TUI_MAX_CHATS.
code_block_style selects the chroma
theme used to syntax-highlight code blocks (e.g. monokai, dracula,
github-dark, nord, solarized-dark). It defaults to monokai; an unknown
name falls back to that default. It can also be set via
TEAMS_TUI_CODE_BLOCK_STYLE.
disable_spell_check turns off compose-box spell checking, and spell_language
selects the dictionary (e.g. en_US, en_GB; empty uses the system default).
Spell checking shells out to the system helper — enchant-2 (the GNOME/gspell
spell layer), falling back to hunspell — so on Fedora/GNOME install the
enchant2 and hunspell packages plus a dictionary (hunspell-en) to enable
it. Misspelled words and their top suggestions appear on a strip beneath the
compose box; the feature is silently disabled when no helper is installed. These
can also be set via TEAMS_TUI_DISABLE_SPELL_CHECK and TEAMS_TUI_SPELL_LANGUAGE.
focus_command is the shell command run when a desktop notification is clicked,
to raise the terminal window (see Click to jump to the chat above). The literal
{token} is replaced with the notification's XDG activation token, which is also
exported to the command as XDG_ACTIVATION_TOKEN. It defaults to the GNOME
Ptyxis command shown above; set it to another terminal/WM command (X11 users can
use wmctrl -a teams-tui or an xdotool invocation) or "-" to disable window
raising while still switching chats and selecting the tmux pane. Overridable via
TEAMS_TUI_FOCUS_COMMAND.
For a sovereign cloud or a custom federation host, override auth_host and
graph_base_url accordingly.
go build -o teams-tui ./cmd/teams-tui
TEAMS_TUI_CLIENT_ID=<client-id> TEAMS_TUI_TENANT_ID=<tenant-id> ./teams-tuiOn first launch you'll see a verification URL and a short code. Open the URL in any browser, sign in (your company login page will appear if your tenant is federated), enter the code, and the TUI loads your chats. Subsequent launches reuse the cached refresh token from your keyring.
If you add a new Graph permission (e.g. Presence.Read.All), the cached token
won't include it and calls will fail with 403 InsufficientPrivileges. The app
detects this automatically — on the next launch it sees the cached token no
longer covers the requested scopes and starts a fresh sign-in (with the new
consent prompt). To force it immediately:
./teams-tui --logout # clears the cached token from the keyring
./teams-tui # next launch prompts for sign-in + new consentMake sure admin consent is granted for the new permission in the app registration first if your tenant requires it.
| Key | Action |
|---|---|
tab / shift+tab |
Move focus between Chats / Messages / Compose |
↑/↓ j/k |
Navigate the focused pane; in Messages, move the message selection |
| click | Focus the clicked pane: a chat row opens that chat (Chats), a message selects it (Messages), the compose box puts the cursor on the clicked character |
| click + drag | Highlight text in the Messages pane or the compose box (drag over the text you want to select) |
| scroll ↑/↓ | Scroll the conversation (the sidebar is not wheel-scrolled: use the keys, a click, or page it with a horizontal scroll) |
| scroll ←/→ | Page the chat list back/forward — one page per swipe — landing on the chat at the top of that page (works anywhere on screen; needs a tilt wheel or a trackpad's sideways swipe) |
y / c |
Copy the highlighted text selection to the system clipboard (Messages) |
ctrl+c |
Copy the highlighted compose-box selection to the system clipboard; with nothing highlighted, quits |
enter |
Open selected chat (Chats) / send message (Compose) / start chat (Contacts) |
r |
React to the selected message (Messages): opens a searchable emoji picker; reacting with an emoji you already used removes it |
q |
Quote-reply (Messages): with no text highlighted, starts a native Teams reply to the whole message (shows a preview banner above the composer; enter to send, esc to cancel). With text highlighted, prefills just that selection into the composer as a quote (sent as an inline quote block) |
alt+enter |
Insert a newline in the compose box |
: + 2 chars |
Open the inline emoji picker while composing (↑/↓ select, tab/enter insert, esc close) |
ctrl+j |
Open the full emoji browser while composing (mnemonic: emoJi): lists every emoji and filters as you type (↑/↓ select, enter insert at cursor, esc close) |
@ |
In a group/meeting chat, open the participant picker to @-mention someone (↑/↓ select, tab/enter complete, keep typing to filter, esc dismiss) |
ctrl+f |
Open the spelling correction picker while composing: choose a word → suggestion (↑/↓ select, enter apply, esc close) |
/ |
Filter the chat list / search contacts |
ctrl+o |
Toggle the sidebar between Chats and Contacts (start a new chat) |
ctrl+e |
Edit a message: the selected message in the Messages pane if it's yours, otherwise your most recent message |
ctrl+y / click |
Open an image in your default viewer/browser (ctrl+y = newest; click a placeholder for that one) |
ctrl+v |
Paste an image from the clipboard and attach it to the next message (type a caption, then enter to send; esc to discard) |
esc |
Drop a compose-box text selection; otherwise clear the compose box (empties typed text, cancels an edit or reply, discards a staged image) |
ctrl+r |
Refresh now |
ctrl+s |
Open the status picker (set your presence) |
ctrl+g |
Toggle full help (esc also closes it): all key bindings, plus a legend for the sidebar's symbols |
ctrl+c |
Quit (see above: copies first when compose text is highlighted) |
The expanded help (ctrl+g) ends with this legend, so you don't have to come
back here for it.
| Symbol | Meaning |
|---|---|
[>] |
One-to-one chat |
[#] |
Group chat |
[@] |
Meeting chat |
| bold orange row | Chat with unread messages |
● green |
Available |
● red |
Busy, in a call, in a meeting, or presenting |
⊘ red |
Do not disturb / focusing |
◐ amber |
Away, be right back, or out of office |
○ grey |
Offline or unknown |
The presence dots appear on rows in Contacts mode (ctrl+o), in the
conversation header, and next to your own status in the footer.
cmd/teams-tui program entrypoint, signal handling, wiring
internal/config config loading (env + JSON) and endpoint URLs
internal/auth OAuth device-code flow, refresh, keyring token store
internal/graph Microsoft Graph client (chats, messages, calendar) + types
internal/notify desktop notifications (beeep + D-Bus actions), degrade-gracefully
internal/focus raise terminal window + select tmux pane on notification click
internal/ui Bubble Tea v2 model/update/view, commands, components
internal/ui/styles Lip Gloss styles
The UI follows The Elm Architecture: all Graph/auth I/O happens inside
tea.Cmds that return typed messages, which the single Update function folds
into the model. Polling is driven by tea.Tick.
Code blocks round-trip through three places that share the same fence
convention: graph/code.go parses Teams' stored HTML (its <codeblock>
element and <pre>/<code>) into Markdown-ish fences, graph/compose.go
converts outgoing compose text back into the <pre><code> HTML Teams expects,
and ui/highlight.go syntax-highlights the rendered block using a
chroma theme (code_block_style).
- Reading tokens is intentionally avoided in code; tokens are treated as opaque per Microsoft guidance.
- Message history is fetched per chat on demand and refreshed on the poll tick.
- Quote-replies default to Teams' native format: pressing
qon a message (with nothing highlighted) sends amessageReferenceattachment (graph/quote.go,Client.SendMessageReply) — the same structure the desktop client stores, so it shows as a real threaded reply. Incoming replies (from any client) carry the quoted message as amessageReferenceattachment too;Message.PlainText()resolves it into> Sender wrote:/> quoted textlines, which the renderer styles with a left bar (ui/view.go). - The native format references a whole message by id, so it can't quote a
sub-range. When text is highlighted and you press
q, the selection is instead prefilled into the composer as>-prefixed lines and sent as an inline<blockquote>(graph/compose.go) — quoted text in every client, just not a threaded reply. Incoming/older<blockquote>quotes are understood on the receive side too (graph/text.go). - Chats with unread messages are shown in orange text in the sidebar (the
selected chat keeps its pink highlight). Unread state comes from Graph's
per-chat
viewpointread marker; opening a chat marks it read locally and on the server (viamarkChatReadForUser, covered byChat.ReadWrite), so it syncs to your other devices. Your own most recent message never counts as unread. - This is a chat-focused client; it does not implement calls, channels/teams browsing, files, or app tabs.
- Reactions are rendered and can be added/removed (
ron a selected message, viasetReaction/unsetReaction, covered byChat.ReadWrite). Quote-replies are sent as a native TeamsmessageReferenceand rendered with a left bar, so they show as a proper reply for every participant. - Images in messages are shown as placeholders (
🖼 [n] name); pressctrl+yto open the newest one, or click a placeholder to open that specific image, in your OS default image viewer/browser (inline Graph hosted content is downloaded with your token first). Other attachment types are not yet rendered. - You can highlight message text with a click-and-drag in the Messages pane,
then
y/cto copy it to the system clipboard orqto quote just that selection into a reply. Copying shells out to a platform clipboard helper, so the relevant tool must be installed:wl-copy(Wayland) orxclip(X11) on Linux,pbcopyon macOS, andclipon Windows. (The app captures the mouse for its own selection; to fall back to your terminal's native selection, hold the modifier your terminal uses to bypass app mouse reporting — oftenshift.) - Clicking any pane focuses it, so the keyboard follows the mouse; clicking a chat row also opens that chat. The wheel scrolls the conversation, and a horizontal wheel (tilt wheel or a trackpad's sideways swipe) pages the chat list from anywhere on screen, opening the chat at the top of the page it lands on — from there, arrow through the page or click a row. A trackpad reports one swipe as a burst of scroll events, so paging is throttled to one page per gesture (swiping back the other way takes effect immediately). The wheel deliberately doesn't scroll the sidebar itself: moving the highlight opens the chat it lands on, so free-running wheel scrolling would be a stream of chat loads. Typing a printable character while the sidebar has focus still jumps straight to the compose box.
- The compose box takes the mouse too: click to put the cursor on a character,
or click-and-drag to highlight text.
ctrl+ccopies the highlight (and only quits when nothing is highlighted), and typing, pasting orbackspace/deletereplaces it, as in any editor.escdrops the highlight. - You can paste an image from the clipboard with
ctrl+vto send it inline. This shells out to a platform clipboard helper, so the relevant tool must be installed:wl-paste(Wayland) orxclip(X11) on Linux,osascripton macOS, andpowershellon Windows. The image is sent as Teams hosted content viaChat.ReadWrite(no extra scope required); any text you type becomes the image's caption.