Documentation
¶
Index ¶
- Constants
- func BackupFile(path string) (string, error)
- func ConfigPath() string
- func DetectKeyCollisions(cfg *Config) []string
- func NormalizeKey(s string) string
- func ResolveComposeEditing(setting string) string
- func ResolveEmojiWidth(v string) string
- func ResolveHyperlinks(v string) string
- func ResolveInlineImages(v string) string
- func Save(cfg *Config) error
- func SaveTo(path string, cfg *Config) error
- func SortChanges(changes []MigrationChange)
- type Config
- type KeyConfig
- type MediaConfig
- type MigrationChange
- type NotificationConfig
- type RawFile
- type StorageConfig
- type TelegramConfig
- type UIConfig
Constants ¶
const ( DefaultSessionFile = "~/.local/share/tele-tui/session.json" DefaultFilesDir = "~/.local/share/tele-tui/files" DefaultDownloadDir = "~/Downloads" )
Default storage locations, in the portable "~/" form. defaultConfig expands them for the running app; -migrate-config writes these literals so a generated config stays portable instead of hardcoding one machine's home directory.
const ( // InlineImagesNever shows the metadata card in the thread and hands // the picture to the platform viewer on Enter. The right answer over a // slow link, and on a terminal whose image support is a guess. InlineImagesNever = "never" // InlineImagesOnOpen shows the card in the thread and draws the picture // full-pane when the reader OPENS it. The default, and the name is // literal: "on open" is when the art appears, not a condition under // which it appears in the history. InlineImagesOnOpen = "on_open" // InlineImagesAlways also draws an eight-row preview in the thread, // which is the only setting that puts art in the history at all. InlineImagesAlways = "always" )
Inline-image policies for UIConfig.InlineImages.
const ( // HyperlinksAuto emits OSC 8 only on terminals known to understand it // (theme.SupportsHyperlinks). The default, and an allowlist: a // terminal that prints the sequence instead of acting on it puts a URL // in the middle of somebody's message. HyperlinksAuto = "auto" // HyperlinksNever never emits them. Links stay cyan and underlined, // which is the affordance; OSC 8 only adds the click. HyperlinksNever = "never" // HyperlinksAlways emits them regardless — for a terminal the // allowlist does not know, or for tmux with allow-passthrough on, // which cannot be detected from the environment. HyperlinksAlways = "always" )
Hyperlink policies for UIConfig.Hyperlinks.
const ( // NotifyMethodAuto asks the terminal where it is known to understand // the sequence and the system otherwise. The default. NotifyMethodAuto = "auto" // NotifyMethodTerminal always asks the terminal, for one the allowlist // does not know. A terminal that does not understand it prints it. NotifyMethodTerminal = "terminal" // NotifyMethodSystem always uses the platform notifier: notify-send on // Linux, osascript on macOS — which posts as Script Editor, because a // command-line binary has no bundle of its own to post from. NotifyMethodSystem = "system" )
Delivery methods for NotificationConfig.Method.
The strings are defined here, beside the other policy fields, so this package stays free of the one that implements them — internal/notification parses the same three values.
const ( // EmojiWidthAuto measures with the Unicode tables and reserves a cell // on top for every composition rule, so an over-reservation shows as a // gap rather than as a row overwriting its neighbour. The default, and // the only value that is a guess. EmojiWidthAuto = "auto" // EmojiWidthComposed says this terminal applies every composition // rule. The tables are then right and nothing is reserved on top — // which is what closes the gap between the folder tabs and the clock. EmojiWidthComposed = "composed" // EmojiWidthSeparate says it applies none of them: U+FE0F is ignored // and joined or paired sequences are drawn as their parts. EmojiWidthSeparate = "separate" )
Emoji-width declarations for UIConfig.EmojiWidth.
This is one setting rather than two because the terminals that get it wrong get it wrong consistently: one that honours U+FE0F also composes ZWJ sequences and flags. What it cannot be is inferred — the widths differ in opposite directions, so no single "narrow" or "wide" describes them.
const ( // ComposeEditingEmacs is the readline keymap (ctrl+a/e/b/f/k/u/w/d). ComposeEditingEmacs = "emacs" // ComposeEditingVi is the modal vi keymap. ComposeEditingVi = "vi" // ComposeEditingAuto infers the keymap from the user's $EDITOR. ComposeEditingAuto = "auto" )
Line-editing keymaps for UIConfig.ComposeEditing.
Variables ¶
This section is empty.
Functions ¶
func BackupFile ¶
BackupFile copies path to path+".bak", byte for byte, at mode 0600. The migration re-marshals the config through the TOML encoder, which silently drops comments and reorders tables; the backup is the only copy of what the user actually wrote — and it holds the same api_hash and phone number as the original, so it gets the same restrictive mode.
The backup lands beside the *resolved* file. With the usual dotfiles layout (~/.config/tele-tui/config.toml symlinked into ~/dotfiles) the backup belongs next to the real file in the dotfiles directory, where the content it is protecting actually lives — not next to the symlink.
An existing backup is never overwritten. Someone who has already migrated once and is migrating again would otherwise lose their real original to a copy of the already-migrated file; the second backup gets a timestamp suffix instead.
func ConfigPath ¶
func ConfigPath() string
ConfigPath returns the config file the app would load: TELETUI_CONFIG when set, otherwise the default location — whether or not it exists. Callers that need to know if it exists should stat it.
func DetectKeyCollisions ¶
DetectKeyCollisions reports keys fields whose bindings cannot all work, as human-readable lines. Two kinds are found.
**Within config.** Two fields the same dispatcher matches, set to one key. Filling in new fields can create a collision the user never made: someone who bound search to "?" gets help = "?" from this migration, and only one of the two can win. The migration will not silently rewrite a deliberate choice, so the honest thing is to name the clash and let the user decide.
**Across the package boundary.** A component-dispatched field (see componentDispatchedFields) set to a key internal/app claims first. This is the case that shipped broken: reply = "q" was accepted, advertised on the help card as Reply, and quit the application when pressed, because app-level dispatch matched "q" before the chat view ever saw the event. The reservation is measured against keys.AppReserved, so it follows the user's own config — moving quit_browsing to f9 frees "q", and this stops reporting it.
What remains unchecked, and why:
- The one inert field (forward) reaches no dispatcher, so a shared value there means nothing and is ignored.
- Keys the OTHER components hardcode: chatview's g/G, n/N, ctrl+f, ctrl+u/ctrl+d, enter/o/s; chatlist's arrows, [ / ] and 1-9; the composer's readline and vi chords. Binding a keys field onto one of those still collides silently here. The chat view resolves its own share at runtime — a configured binding that would shadow a key it already owns is dropped rather than allowed to win — but it does so quietly, and this function is where that ought to become a message. Naming those sets here would mean a second, hand-copied record of them; the honest fix is for each component to publish its claimed set the way internal/app now does through keys.AppFixed.
- Whether the *combination* is usable. Two bindings can be collision-free and still miserable.
The help overlay shows the real, merged map; this checks only what config.toml can express.
func NormalizeKey ¶
NormalizeKey canonicalizes a user-configured key string to the form produced by bubbletea's Key.Keystroke(): lowercased, with modifier and key aliases resolved and modifiers emitted in Keystroke's fixed order (ctrl, alt, shift, meta, hyper, super). An empty input returns empty, so callers can detect "not configured" and fall back to a built-in default.
Examples: "ALT+L" -> "alt+l", "Option+1" -> "alt+1", "shift+ctrl+a" -> "ctrl+shift+a", "Escape" -> "esc", "ctrl++" -> "ctrl++".
Anything that is not a recognized modifier terminates the modifier prefix and is taken (together with the rest of the string) as the key name, so a literal "+" binding survives intact.
func ResolveComposeEditing ¶
ResolveComposeEditing turns a configured UIConfig.ComposeEditing value into a concrete ComposeEditingEmacs or ComposeEditingVi.
An explicit "emacs" or "vi" wins. Everything else — "auto", empty (an older config.toml predating the field), or an unrecognized value — infers the keymap from $VISUAL, falling back to $EDITOR: if the editor's command name contains "vi" (vi, vim, nvim, gvim, view) the answer is vi, otherwise emacs. That also makes emacs the answer when no editor is set, matching the shell convention that readline bindings are the default.
An unrecognized value is treated as "auto" rather than rejected, so a typo in config.toml degrades to a sensible keymap instead of breaking startup.
func ResolveEmojiWidth ¶
ResolveEmojiWidth normalises UIConfig.EmojiWidth, falling back to the default for an empty or unrecognised value — a typo here should cost the user the setting, not the client.
func ResolveHyperlinks ¶
ResolveHyperlinks normalises UIConfig.Hyperlinks to one of the three policies, treating anything unrecognised as the default rather than failing: a typo in a cosmetic setting should not stop the client starting.
func ResolveInlineImages ¶
ResolveInlineImages normalises UIConfig.InlineImages, falling back to the default for an empty or unrecognised value.
Unrecognised falls back rather than failing: a typo in this field should cost the user the setting, not the client.
func Save ¶
Save writes the config to the path the app will read back: $TELETUI_CONFIG when set, otherwise the default location. See ConfigPath.
Writing to the default location unconditionally would break the first-run setup wizard under $TELETUI_CONFIG: the credentials it collects would land in a file Load never looks at, and the next launch would ask for them again.
func SaveTo ¶
SaveTo writes the config to an explicit path. Save always writes to the default location; migration has to write back to the file it read, which TELETUI_CONFIG can move.
func SortChanges ¶
func SortChanges(changes []MigrationChange)
SortChanges orders a migration summary by field name, so successive runs and successive versions produce comparable output.
Types ¶
type Config ¶
type Config struct {
Telegram TelegramConfig `toml:"telegram"`
Storage StorageConfig `toml:"storage"`
UI UIConfig `toml:"ui"`
Media MediaConfig `toml:"media"`
Notifications NotificationConfig `toml:"notifications"`
Keys KeyConfig `toml:"keys"`
}
type KeyConfig ¶
type KeyConfig struct {
Quit string `toml:"quit"`
// QuitBrowsing quits from the chat list and the chat view only, where
// a bare letter cannot be mistaken for typing — the composer owns
// printables and never sees it. Quit (and the hardcoded
// ctrl+q) work from everywhere including the composer. Default "q".
//
// An unsent draft or a pending attachment turns it into a confirm
// rather than an immediate exit, so a single keystroke cannot discard
// a message being written.
QuitBrowsing string `toml:"quit_browsing"`
FocusChatList string `toml:"focus_chat_list"`
FocusChatView string `toml:"focus_chat_view"`
FocusComposer string `toml:"focus_composer"`
Search string `toml:"search"`
Contacts string `toml:"contacts"`
// ContactsAlt is a second, alt-free binding for the contacts overlay,
// so the overlay stays reachable on terminals that cannot report Alt
// (see the macOS notes below). Default f4.
ContactsAlt string `toml:"contacts_alt"`
// Help opens the keybinding overlay. Default "?".
Help string `toml:"help"`
// GlobalSearch searches every chat. Search ("/") does the same from
// every panel except the chat view, where vi convention makes "/" mean
// "find in this buffer"; GlobalSearch is the panel-independent binding.
// Default ctrl+g.
GlobalSearch string `toml:"global_search"`
NextChat string `toml:"next_chat"`
PrevChat string `toml:"prev_chat"`
Reply string `toml:"reply"`
EditMessage string `toml:"edit_message"`
DeleteMessage string `toml:"delete_message"`
Forward string `toml:"forward"`
ScrollUp string `toml:"scroll_up"`
ScrollDown string `toml:"scroll_down"`
PageUp string `toml:"page_up"`
PageDown string `toml:"page_down"`
// NextFolder/PrevFolder cycle the chat list's folder tabs.
NextFolder string `toml:"next_folder"`
PrevFolder string `toml:"prev_folder"`
}
KeyConfig lists user-configurable key bindings. Not every field is currently consulted:
- Wired, dispatched by internal/app itself (which normalizes the value via NormalizeKey and falls back to its built-in default when empty): Quit, QuitBrowsing, FocusChatList, FocusChatView, FocusComposer, Search, GlobalSearch, Contacts, ContactsAlt, Help, NextFolder, PrevFolder, NextChat, PrevChat. Note FocusChatList/View/Composer are wired *in addition to* their hardcoded alt+1/2/3 shortcuts, which always work regardless of configuration.
- Wired, resolved by internal/app and handed to the chat view, which implements them: Reply, EditMessage, DeleteMessage, ScrollUp, ScrollDown, PageUp, PageDown. Same normalization and defaulting; the difference is only which layer matches the key. Two rules apply there and nowhere else. Reply/EditMessage/DeleteMessage are mnemonics: a value REPLACES the built-in r/e/d. ScrollUp/ScrollDown/ PageUp/PageDown are motions: a value is ADDED to the built-in j/k, the arrows and pgup/pgdown, which always keep working. And a value that collides with a key the chat view already claims is dropped rather than allowed to shadow it. The chat view's remaining keys (g/G, ctrl+u/ctrl+d, n/N, ctrl+f, enter/o/s) are hardcoded there, as are the chat list's arrows and digits and the composer's line editing — see the keymap table in internal/app/keymap.go.
- Unwired (parsed and preserved on save, but not consulted anywhere — kept so existing config.toml files round-trip cleanly; a value here is silently inert): Forward. There is no forward-a-message feature to bind it to; the field exists only so config files that set it still load.
A component-dispatched field set to a key internal/app claims first is not merely shadowed — it is dead, because app-level dispatch runs before the focused panel sees the event. reply = "q" used to be accepted, advertised on the help card as Reply, and quit the application when pressed. Two things now prevent that: the chat view is told what the app has claimed (keys.AppReserved) and refuses such a binding, keeping its built-in letter; and DetectKeyCollisions reports it, so the refusal is explained rather than silent.
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. It does not shadow it in the composer: typing is only ever entered deliberately (i, c, Tab, the focus keys, or a click), and once the composer has focus almost nothing is claimed at app level — see the exception list in the keymap table in internal/app/keymap.go. A bare printable is therefore a reasonable binding here, though a modifier still reads more clearly.
macOS: Alt bindings and the Option key ¶
The default alt+… bindings only reach the app if the terminal reports Option as a modifier. Terminals differ:
- Ghostty: macos-option-as-alt = true (default "false" on macOS — confirmed in field testing to be why alt bindings work in kitty but not in a stock Ghostty).
- 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.
While Option is not reported as a modifier, macOS composes the character itself and the terminal sends only that: Option+1 arrives as a bare "¡" with no modifier bit, indistinguishable from the user typing "¡". This is not something the Kitty keyboard protocol fixes — the composition happens before the terminal builds the key event — and no amount of key matching can recover the binding.
So every alt binding has an alt-free alternative: f1/f2/f3 for panel focus, f4 for contacts (ContactsAlt), ctrl+g for global search (GlobalSearch), and "[" / "]", the left/right arrows or the 1-9 jump for the folder tabs while the chat list is focused. (Bare h/l used to be that fallback for the folder tabs; they now move between panels, which is what left/right means in a two-column layout — see internal/app/keymap.go.) Rebinding here works too — prefer ctrl+… or a function key.
type MediaConfig ¶
type MediaConfig struct {
ImageProtocol string `toml:"image_protocol"`
MaxImageWidth int `toml:"max_image_width"`
MaxImageHeight int `toml:"max_image_height"`
VoicePlayer string `toml:"voice_player"`
VideoPlayer string `toml:"video_player"`
AutoDownloadPhotos bool `toml:"auto_download_photos"`
AutoDownloadLimitMB int `toml:"auto_download_limit_mb"`
// AutoDownloadVoice is read back and written out, and nothing consults
// it. Voice notes are never prefetched: one is fetched when you press
// space on it, which is the only moment anybody wants the bytes, and
// turning that off would mean a key that does nothing. Kept for config
// round-tripping — see TimestampFormat for the same reasoning at
// length.
AutoDownloadVoice bool `toml:"auto_download_voice"`
}
type MigrationChange ¶
type MigrationChange struct {
// Field is the TOML key, qualified by its table (e.g. "keys.contacts").
Field string
// Old is the value the config carried, empty when Absent.
Old string
// Absent distinguishes a key the file never had from one it set to the
// empty string. Both read as "" in Old, but they are different
// mistakes: the first is an old config missing a field added since, the
// second is someone who wrote `contacts = ""` and deserves to see that
// their (broken) setting was replaced rather than merely filled in.
Absent bool
// New is the value written in its place.
New string
// Removed marks a field the client no longer has. The key is dropped
// from the rewritten file rather than replaced, and the summary says so
// — a user who tuned it deserves to learn it stopped doing anything,
// which is not the same news as a value being changed.
Removed bool
}
MigrationChange records one field the migration rewrote.
func Migrate ¶
func Migrate(cfg *Config, raw *RawFile) []MigrationChange
Migrate brings an older config up to the current defaults in place and reports what it changed. It is pure apart from mutating cfg — no file is read or written — so the caller owns backup and save ordering.
cfg is the config to rewrite and save. raw is the same file parsed without defaults applied (LoadRawFile), and is consulted only to tell "the file did not have this key" from "the file set it to the current default". Load fills absent fields from defaultConfig, so without raw a config written before help/global_search/contacts_alt existed would look like it already had them and the summary would not mention the keys it gained. raw may be nil, in which case cfg's own empty fields count as absent.
Per field, in the keys table:
- absent: filled with the current default, and reported so the user can see which keys the file gained.
- equal to a known stale default (see staleKeyDefaults): replaced with the current default, because the user inherited it rather than chose it. Comparison is through NormalizeKey, so "CTRL+K" counts.
- anything else: left alone. A deliberate customization survives an upgrade even when it collides with something.
It also fills two fields outside keys that were introduced later: ui.compose_editing and storage.state_file. state_file is written as the path the client would otherwise derive, so the location becomes explicit rather than implied.
func (MigrationChange) String ¶
func (c MigrationChange) String() string
String renders a change for the migration summary.
type NotificationConfig ¶
type NotificationConfig struct {
Enabled bool `toml:"enabled"`
Sound bool `toml:"sound"`
ShowPreview bool `toml:"show_preview"`
// Method is who posts the notification: "auto" (the default — the
// terminal where it is known to understand the sequence, the system
// otherwise), "terminal", or "system". See internal/notification for
// why the terminal is usually the right answer, and why macOS labels
// the system path "Script Editor".
Method string `toml:"method"`
}
type RawFile ¶
type RawFile struct {
// Config is the file parsed with no defaults applied.
Config *Config
// contains filtered or unexported fields
}
RawFile is a config file as written: parsed without defaults, plus the structure of what was actually in it.
Load applies defaults, which erases the difference between "the file did not have this key" and "the file set it to today's default". The migration needs that difference to report honestly, and needs the unexpanded path strings so it does not rewrite a user's "~/..." into an absolute path.
func LoadRawFile ¶
LoadRawFile parses a config file without applying any defaults. See RawFile; Load is what the app itself wants.
func (*RawFile) MissingSections ¶
MissingSections returns the config tables the file does not have, which a rewrite will add in full. Sorted.
type StorageConfig ¶
type StorageConfig struct {
SessionFile string `toml:"session_file"`
// FilesDir is the media CACHE: where downloads land so a photo drawn
// twice is fetched once. It is not where "save this" saves to — see
// DownloadDir — and a user who set files_dir expecting the latter got
// a cache directory full of files with server-side names.
FilesDir string `toml:"files_dir"`
// DownloadDir is where `s` puts a copy, under the sender's own
// filename. Defaults to the platform download folder, because that is
// where a person looks for a thing they just saved.
DownloadDir string `toml:"download_dir"`
// StateFile is the bbolt database holding the update-sequence state
// (pts/qts/seq/date) and the peer access-hash cache, so updates that
// arrived while the app was offline can be recovered on the next start.
// Empty (the default) means "state.db" next to SessionFile.
StateFile string `toml:"state_file"`
}
type TelegramConfig ¶
type UIConfig ¶
type UIConfig struct {
Theme string `toml:"theme"`
// TimestampFormat and DateFormat are READ BACK AND WRITTEN OUT, and
// nothing consults them. Every time on screen has a fixed form chosen
// for the column it sits in: the thread's clock is 15:04 because the
// grid gives it five cells and puts the date in a day divider, the
// chat list is relative because a chat list is read for recency, and a
// day divider names the day. A Go layout string cannot express those,
// and one that overrode all three would break the column widths the
// frame is built on.
//
// Kept so an existing config round-trips rather than losing keys on
// -migrate-config. Marked here, and in config.example.toml, so nobody
// spends an afternoon finding out they do nothing.
TimestampFormat string `toml:"timestamp_format"`
DateFormat string `toml:"date_format"`
// InlineImages governs WHERE a photo is drawn:
// [InlineImagesNever], [InlineImagesOnOpen] (the default), or
// [InlineImagesAlways]. Only the last puts art in the thread, and only
// bounded — see render.inlineArtRows for why the bound is not
// negotiable.
InlineImages string `toml:"inline_images"`
// Hyperlinks governs OSC 8 terminal hyperlinks on links in a message:
// [HyperlinksAuto] (the default), [HyperlinksNever], or
// [HyperlinksAlways].
Hyperlinks string `toml:"hyperlinks"`
// EmojiWidth declares how this terminal draws emoji sequences that
// have a composition rule: [EmojiWidthAuto] (the default),
// [EmojiWidthComposed], or [EmojiWidthSeparate]. It is a declaration
// because it cannot be detected — see internal/ui/cell for why.
EmojiWidth string `toml:"emoji_width"`
// Rail shows the right-hand context rail — pinned message, members,
// shared files — on a terminal wide enough for it. Off by default: it
// costs 30 columns, and they come out of the thread.
Rail bool `toml:"rail"`
// ComposeEditing selects the composer's line-editing keymap:
// [ComposeEditingEmacs], [ComposeEditingVi], or [ComposeEditingAuto]
// (the default) to infer it from $VISUAL/$EDITOR. Resolve it with
// [ResolveComposeEditing]; never read the raw value.
ComposeEditing string `toml:"compose_editing"`
// ParseMarkdown enables the Telegram Desktop markdown subset in
// outgoing messages and captions (**bold**, __italic__, `code`,
// “`pre“`, ~~strike~~, ||spoiler||, [text](url)).
//
// Defaults to FALSE: what you typed is what gets sent. The composer has
// no preview, so with parsing on silently by default the first time a
// user notices is when a message has already left — and the syntax
// overlaps with things people paste verbatim. __init__ arrives as
// init, a snippet full of ** loses it, a table of || collapses. Opting
// in means knowing that transformation happens.
//
// -migrate-config turns it on for existing configs, where it reports
// the change, so upgraders get the feature but are told about it.
ParseMarkdown bool `toml:"parse_markdown"`
}