Documentation
¶
Index ¶
- Constants
- func BackupFile(path string) (string, error)
- func ConfigPath() string
- func DetectKeyCollisions(cfg *Config) []string
- func IsBarePrintableKey(key string) bool
- 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 ResolveQuitKey(configured string) (key string, refused bool)
- func Save(cfg *Config) error
- func SaveTo(path string, cfg *Config) error
- func SortChanges(changes []MigrationChange)
- func StartupWarnings(cfg *Config) []string
- 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" // DefaultOutboxDir is the drop-box a remote caller may send files from // when [StorageConfig.SendDirs] says nothing. It is deliberately its // own directory rather than DefaultDownloadDir: ~/Downloads is where a // browser puts things, so handing it to an MCP agent would restore most // of the exposure the allowlist exists to remove. DefaultOutboxDir = "~/.local/share/tele-tui/outbox" )
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.
const DefaultQuitKey = "ctrl+q"
DefaultQuitKey is what ResolveQuitKey falls back to. It is a chord on purpose: quit is matched before every focus gate, so a bare letter here is a letter that cannot be typed anywhere in the client.
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 IsBarePrintableKey ¶ added in v0.0.12
IsBarePrintableKey reports whether a NORMALIZED binding types a character when it is pressed: a single unmodified printable ("x", "?", "+"), or "space". A chord ("ctrl+x"), a named key that produces no text ("esc", "f1", "pgup") and the empty string are all false.
It answers one question: would binding this shadow a character somebody types? Only the bindings matched ahead of the composer have to ask it.
func NormalizeKey ¶
NormalizeKey canonicalizes a user-configured key string to the form produced by bubbletea's Key.Keystroke(): modifier and key aliases resolved, modifiers lowercased and 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++".
A lone printable keeps its case ¶
"J" stays "J". Case is not decoration on an unmodified letter, it is the binding: a shift+j press reports Keystroke() "shift+j" and String() "J", and keys.Press matches an unmodified key on either spelling — so "J" matches it and "j" does not, while a plain j press matches "j" and not "J". next_chat = "J" and the chat list's own j are two different keys, and lowercasing the first turns the second into chat navigation.
That is a real regression rather than a hypothetical one: the shipped defaults set next_chat/prev_chat to J/K, Load fills them into every config, and with them folded to j/k the app-level handler — which runs before the focused panel — took the chat list's cursor motion.
Only the lone-printable case is preserved. Anything with a modifier goes on being lowercased, because there Keystroke() is the only spelling that can match and it is lowercase: "ALT+L" is alt+l, not alt+L. A user who means the shifted letter with a modifier writes the modifier out ("alt+shift+l"), which is what the terminal reports.
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 ResolveQuitKey ¶ added in v0.0.12
ResolveQuitKey is the binding KeyConfig.Quit resolves to, and whether the configured value was refused (decision I-13).
quit is the one field where a bare printable is not merely unwise but broken: it is matched before every focus gate, so quit = "x" meant that pressing x while writing a message quit the application instead of typing an x. Nothing rejected that — DetectKeyCollisions only compares bindings against each other, never against "is this a character someone types" — so the documented advice was to avoid it, which is not the same thing as it not happening.
The refusal keeps the default rather than leaving quit unbound: a client with no way out is worse than one that ignored a line of config, and StartupWarnings says which it did.
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.
func StartupWarnings ¶ added in v0.0.12
StartupWarnings is everything about the keys table worth telling the user before the TUI takes the screen: a refused quit binding, and bindings that cannot all work.
At startup, not only under -migrate-config (decision I-13). A warning somebody sees only if they happen to run a migration is a warning about a client they are already using with the broken binding in it.
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"`
}
func (*Config) PrepareSendRoots ¶ added in v0.0.18
PrepareSendRoots returns Config.SendRoots ready to use, creating the default outbox when it is one of them and does not exist yet. Roots that are still missing are returned in missing so the caller can say so: [telegram.ResolveAllowedSendPath] silently skips a root it cannot resolve, so an operator who typo'd a send_dirs entry would otherwise see only "outside the allowed directories" on every send.
The outbox is created because it is the documented default — a drop-box nobody can put a file in is not a default, it is a dead end. An operator's own directory is never created: making a typo real is worse than reporting it.
Call this from the service frontends only, after the Telegram client is constructed (which creates files_dir), and treat err as a warning rather than fatal: an unwritable outbox narrows what can be sent, it does not make the server unsafe to run.
func (*Config) SendRoots ¶ added in v0.0.18
SendRoots returns the directories a remote caller may send files from, in the order they are searched: the media cache first, then the configured StorageConfig.SendDirs (or DefaultOutboxDir when that is empty).
The media cache is always a root and is not configurable. The client hands those paths out itself — download_media returns one — so refusing to send back a file it just named would be incoherent. Everything else is the operator's list.
Blank entries are dropped rather than ignored downstream: an empty root means "no root" to [telegram.ResolveAllowedSendPath], and a list of nothing but blanks must reject every path rather than accept one.
type KeyConfig ¶
type KeyConfig struct {
// Quit quits from anywhere, without asking. A bare printable is
// refused here: it is matched before every focus gate, so it would be
// untypable in a message. See [ResolveQuitKey]. Default ctrl+q.
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. 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.
// Default "q".
QuitBrowsing string `toml:"quit_browsing"`
// Search searches the buffer in front of you: in-chat find from the
// chat view, a live filter over the chat list. Default "/".
Search string `toml:"search"`
// GlobalSearch searches every chat, from any panel — the
// panel-independent binding Search cannot be, since vi convention
// gives "/" to the buffer you are looking at. Default ctrl+g.
GlobalSearch string `toml:"global_search"`
// Contacts toggles the contacts overlay. Default "c".
Contacts string `toml:"contacts"`
// Compose moves focus to the composer, opening the cursored chat
// first when it is not already the open one. Default "i".
Compose string `toml:"compose"`
// Help opens the keybinding overlay. Default "?".
Help string `toml:"help"`
// NextChat/PrevChat open the next and previous chat outright, unlike
// the chat list's own j/k which only move the cursor. Defaults "J"
// and "K".
NextChat string `toml:"next_chat"`
PrevChat string `toml:"prev_chat"`
// NextUnread opens the next chat with unread messages, searching down
// from the cursor within the active folder and wrapping once.
// Default "u".
NextUnread string `toml:"next_unread"`
// NextFolder/PrevFolder cycle the folder tabs from either browsing
// panel. Defaults "]" and "[".
NextFolder string `toml:"next_folder"`
PrevFolder string `toml:"prev_folder"`
// Reply/EditMessage/DeleteMessage/Forward act on the cursored message
// in the chat view. Defaults "r", "e", "d", "f".
Reply string `toml:"reply"`
EditMessage string `toml:"edit_message"`
DeleteMessage string `toml:"delete_message"`
// Forward opens the destination picker for the cursored message. It
// was the one field the keymap cut removed for being inert (I-13); it
// is back because it now does something. See issue #39.
Forward string `toml:"forward"`
// MarkRead marks the open chat read without moving the scroll or the
// unread divider. Default "m".
MarkRead string `toml:"mark_read"`
}
KeyConfig lists the user-configurable key bindings. Every field follows one rule (decision I-13):
A value REPLACES the default. A value that collides with anything already bound — another field, or a key a panel owns outright — is REFUSED, the default is kept, and the refusal is reported at startup.
There used to be three rules. Some fields replaced their default, some were an extra spelling ADDED alongside it, and one was accepted, saved and never consulted at all. The three needed a page of documentation to tell apart, and the third let `forward` sit in the shipped example file bound to nothing. It is bound to something now — the removal was of an inert key, not of the idea — and it comes back under the one rule like every other field.
Which layer matches a binding still differs, and still does not change the rule. internal/app dispatches Quit, QuitBrowsing, Search, GlobalSearch, Contacts, Compose, Help, NextChat, PrevChat, NextUnread, NextFolder and PrevFolder itself; it resolves Reply, EditMessage, DeleteMessage and MarkRead and hands them to the chat view, which implements them (see chatview.Keys). Forward is resolved the same way and handed to the chat view, which reports the intent; the app owns the destination picker that follows.
The motions are not configurable, and are not meant to be: j/k, the arrows, g/G, ctrl+e/ctrl+y, ctrl+d/ctrl+u and the page keys are vi's, the chat list's digits and the composer's line editing belong to their components, and a client that let them move would be a client whose documentation could not describe it. See the keymap in docs/interaction-model.md.
A 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 prevent it now: the chat view is told what the app has claimed (keys.AppReserved) and refuses such a binding, keeping its built-in letter; and StartupWarnings reports it, so the refusal is explained rather than silent. The help card shows an action left with no key as "(unbound)".
A binding here shadows that key in the chat list and the chat view. It does not shadow it in the composer: typing is only ever entered deliberately (i, Tab, an action that needs text, or a click), and once the composer has focus almost nothing is claimed at app level. Quit is the exception, and the one field where a bare printable is refused outright — see ResolveQuitKey.
Values are read through NormalizeKey: modifiers and key names are case-insensitive and aliased, but a lone printable letter keeps its case, because on an unmodified key the case is the binding. NextChat is "J" and the chat list's own motion is "j"; they are not the same 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"`
// SendDirs is the allowlist of directories a REMOTE caller may send
// files from: the `send_file` MCP tool and POST /api/send-file. It does
// not constrain the TUI, where the person choosing the file is the
// person running the process.
//
// Empty means [DefaultOutboxDir], which is also what the shipped
// default holds. Read it through [Config.SendRoots], never directly:
// the media cache is always a root and is not listed here.
//
// There is deliberately no spelling of "the cache and nothing else" —
// `send_dirs = []` reads as unset, because a list that is empty by
// accident (a commented-out entry, a cleared value) must not silently
// mean something different from a list that was never written. An
// operator who wants exactly that points send_dirs at files_dir.
//
// This used to be the process's working directory, which nobody chose
// and nothing logged — whatever directory `telegram-mcp serve` happened
// to start in became sendable. For an MCP host launched from a login
// shell that is $HOME, and the caller on the other end is a language
// model reading untrusted incoming messages. The allowlist exists so
// the set has a size somebody picked.
SendDirs []string `toml:"send_dirs"`
}
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"`
}