Configuration
Settings live in ~/.config/eterm/settings.toml and are written on every change
made in the settings window (cmd-,). The file can also be edited by hand;
eterm reads it at startup.
The file is not inside the application, so upgrading never touches it. It also survives being unreadable, which matters more than it sounds: settings are saved on every change, so defaults loaded into memory would otherwise become defaults on disk the moment you touched a toggle.
- A file that cannot be parsed is moved to
settings.toml.brokenand the settings window says so along the bottom, with the reason and the line. Nothing is lost; copy back what you want. - The previous file is kept as
settings.toml.bakbefore each save. - Saving writes a temporary file and renames it over the old one, so a crash or a full disk halfway through cannot leave a half-written file.
- Anything the file does not mention takes its default, and anything eterm does not recognise is ignored — so a file written by an older or a newer version still loads.
Everything below is optional. An empty file is a valid file.
appearance
[appearance]
palette = "palenight" # one of the 22 built-in palettes
font_family = "" # empty picks the best installed monospace font
font_size = 14.0
line_height = 1.4 # multiplier, not pixels
letter_spacing = 0.0 # extra pixels between cells
padding = 18.0 # around the grid
cursor_style = "block" # block, bar, underline
show_footer = true # the directory and title strip at the bottom
highlight_input = true # colour the command line as you type it
warn_destructive = true # and red, with the reason in the footer, when it looks destructive
ligatures = true # => and != drawn as the font joins them
font_features = [] # OpenType features: "ss01", "+cv05", "-liga"
font_fallback = [] # families to try for a character the font lacks
highlight_input turns the first word green when it is a command that exists.
It needs shell integration, which is what reports where
the prompt ends, and it never touches a cell the shell has already coloured —
so a prompt framework or zsh-syntax-highlighting keeps its own work.
warn_destructive paints the whole line red while what is typed has one of the
shapes AI mode holds a proposal back for — rm -rf,
git push --force, DROP TABLE, curl … | sh, about forty of them — and the
footer says which: this deletes files recursively or without asking. It is a
second look offered before the command runs rather than after, and it is only a
look: return still runs it. The red does override a prompt framework's colours,
because a warning that gives way to somebody's yellow is not one. Same
requirement as highlight_input: the shell has to say where the prompt ends.
A word we do not recognise is left alone rather than marked wrong, because it may be an alias or a shell function, and neither of those is visible from here.
The palette list is in the Appearance pane. Eight of them are designed to sit on a shader surface; four of those are light.
font_family is resolved against what is installed. If the named font is
missing, eterm falls back through JetBrains Mono, SF Mono, Berkeley Mono, IBM
Plex Mono, Fira Code, Menlo and Monaco, and the Appearance pane offers only the
ones actually present.
ligatures is the font's calt feature, which is how coding fonts join =>,
!= and ->; off draws each character alone, as it does in a font that has
none. font_features turns other features on and off by their four-letter tags
— the stylistic sets (ss01…) and character variants (cv01…) that JetBrains
Mono, Fira Code or Monaspace ship — and one written there wins over the switch.
A tag that is not four letters and digits is ignored.
font_fallback is where a character the font does not have is looked for, in
order: a Nerd Font for the icons a prompt draws, say. Menlo and Apple Color Emoji
always come after it.
Every character sits in its own cell whatever font drew it. A character two cells wide — an emoji, a CJK character — or a ligature drawn as one glyph for several characters, does not move what comes after it on the row.
motion
[motion]
cursor_blink = true
blink_interval = 530 # milliseconds per half cycle
smooth_scroll = true
animate_surface = false # drift the shader surface
animate_surface is off by default because a moving surface means the window
never idles: about 5% of a core at 30fps. The surface itself costs 0.10ms a
frame when it does move.
shell
[shell]
program = "" # empty means your login shell
args = []
working_directory = "" # empty means home
scrollback = 10000
# Directories put in front of the inherited PATH. ~ is expanded.
path = [
"~/.local/bin",
"~/.cargo/bin",
"/opt/homebrew/bin",
"/opt/homebrew/sbin",
"/usr/local/bin",
]
# Environment variables for the session. These win over everything eterm sets,
# the shell integration included. A leading ~/ is expanded.
[shell.env]
EDITOR = "hx"
path matters more than it looks. A process launched from the Dock inherits
launchd's PATH, which on a stock Mac is four system directories and nothing
else, so cargo and brew are missing even though they work in another
terminal. The Shell pane shows the result: every directory marked inherited,
added or missing, and which of 21 known tools are reachable.
A login shell can still rewrite PATH in its own rc files afterwards. What eterm sets is the starting point, not the last word.
LANG is set for you when nothing else did. An application launched from the
Dock inherits no locale — launchd has none to give — and a shell without one
works in bytes rather than characters: æøå is three characters and six bytes,
and the line editor counts six. Backspace then takes half a letter. eterm sets
LANG from the region macOS is set to, falling back to en_US.UTF-8 when that
region has no locale installed, and leaves it alone when LANG or LC_ALL
already says something.
shell.env is also the way out of the automatic
shell integration: eterm points zsh at a ZDOTDIR of
its own, and setting ZDOTDIR here is what overrules it.
Secrets on screen
cat .env, and the tokens are on the screen — when somebody is looking over
your shoulder, when the screen is shared, and when the screen is about to be
handed to a model. With hide_secrets on, anything shaped like a secret is
drawn as a bar in the accent colour, and shown only while the pointer is over
it. Copying still copies the real text: this is for the shoulder, not for you.
What counts is decided by shape, chosen for precision: GitHub, Anthropic, OpenAI,
AWS, Slack, Stripe and Google tokens by their prefixes and lengths, JWTs, the
header of a private key, and the value of any SOMETHING_KEY=, _SECRET=,
_TOKEN= or _PASSWORD= line of eight characters or more. A commit hash is not
a secret and is left alone.
The same shapes are replaced with [redacted] in everything that leaves for a
model — the screen sent with a first question, the output of every step after
it — and in a block handed to another pane with cmd-shift-s, since what is in
that pane is usually a model too. See AI mode.
A tab that wants you
Agents sit and wait in panes you are not looking at. When a program in a session
asks for you — with the bell, with iTerm2's OSC 9, urxvt's OSC 777 or
kitty's OSC 99, or by saying through OSC 9;4 that it has stopped being busy,
which is how Elyra reports progress — the dot on its tab grows to full size and
full colour, and the Dock bounces if eterm is not the app in front. The mark
clears the moment you type or click in that session.
Claude Code rings the bell by default when it needs an answer; set
preferredNotifChannel to iterm2 and it sends OSC 9 instead, which is heard
the same way.
While you were away
Come back to the window after half a minute or more elsewhere, and if anything happened in the meantime a line under the session says what:
While you were away · claude is waiting for you · cargo test failed with 101 5 min ago · cargo build finished 8 min ago · OK
Each thing on it is a way to the pane it happened in, wherever that pane is —
another tab included — and it leaves the line once read. Waiting comes first,
because a program stopped for an answer is time still being lost; failures
next; then what merely finished, most recent first. Four things at most, and a
count of the rest. Nothing that ended before you left is on it: you saw that.
Leaving again clears it, and away_summary = false turns it off.
keys
[keys]
option_as_meta = false
[keys.bind]
"cmd-shift-t" = "new_tab"
"cmd-k" = "none"
[keys.bind] moves, adds and takes away keys; see
changing a key.
Off leaves the option key to the system, so dead keys and input methods compose:
⌥U then A is ä. On sends ESC-prefixed keys, which is what readline expects
from ⌥B. It cannot be both.
links
[links]
enabled = true # cmd-click opens paths and addresses in plain text
editor = "default" # where a file opens: zed, vscode, cursor, idea … or a template
A program can mark a link with OSC 8, and a few do. Most print
Listening on http://localhost:3000 or --> src/main.rs:12:5 and leave the
rest to whoever is reading. With cmd held, such a thing is underlined; a
cmd-click opens an address in the browser and a file in the editor, at the
line and column the text named — even inside a program that has taken the
mouse, as Claude Code does, because a cmd-click on a link goes to the link
first. localhost:5173, 127.0.0.1:8000 and
0.0.0.0:3000 are read as http; www. too. Quotes, brackets and the
punctuation of a sentence around a link are not part of it. A path is resolved
against the directory the shell reports, and only counts when the file is
actually there — v1.2.3 has a dot in it and is not a file — which is also why
this works best with shell integration. Python's
File "app/x.py", line 42 is understood as well as x.py:42.
Editors are reached by the URL schemes they register — zed://, vscode://,
cursor://, windsurf://, subl://, txmt://, x-bbedit://, nova://, and
idea://, phpstorm://, webstorm://, pycharm://, goland://,
rustrover://, clion:// — so opening a file at a line is opening a URL.
e has no scheme, and is started instead, as its
own command line starts it: open -n -a e.app --args <file>, with the file as
one argument and no shell in between. It is told the file and not the line,
which is all it takes. The Links pane offers only the editors found under
/Applications. default hands the file to whatever macOS opens it with, and
loses the line. Anything else is taken as a template of your own:
editor = "myeditor://open?file={path}&line={line}&column={column}"
{path} is absolute and percent-encoded; {line} and {column} are 1 when
the text named none.
ai
[ai]
enabled = false
provider = "anthropic" # anthropic or open_ai
model = "claude-opus-5"
context_lines = 40 # lines of screen sent with the question
fetched_models = [] # filled in by Refresh in the settings pane
The API key is deliberately not here. See AI mode.
input
[input]
suggestions = true # the rest of a command you have run before, in grey
completions = true # a list of commands, options or paths while typing
Both need shell integration. See Suggestions for what they read and what they leave alone.
review
[review]
enabled = true # cmd-r opens a pull request for reading
Off means the command does not exist and the window cannot be opened. The token is not here — it is in the login keychain, for the same reason the model key is. See Review.
renderer
[renderer]
vsync = true
max_fps = 120
updates
[updates]
check = true # ask elyracode.com once a day whether there is a newer eterm
Nothing but the request goes out, and nothing is fetched or installed until you press the button. See Updates.
other
[other]
notify_after = 20 # bounce the Dock icon for commands this slow; 0 is off
confirm_on_quit = false
copy_on_select = false # copy a selection as soon as it is made
bell = true # a program ringing the bell is asking for you
hide_secrets = true # tokens and keys drawn as a bar, and kept from models
away_summary = true # on coming back, one line on what finished and who waits
notify_after only applies when eterm is not the application in front — you
are already looking at it otherwise. A command that succeeded bounces the Dock
icon once; one that failed keeps bouncing until you come back.
hooks
[hooks]
on_command_finished = "" # post a finished command here: this Mac or https; empty is off
min_seconds = 0 # only commands that ran at least this long
only_failures = false # only commands that failed
What is posted, and how to point it at a Huskarl bot, is in Huskarl.
huskarl
[huskarl]
enabled = true # show Huskarl's waiting proposals on coming back and in cmd-j
See Huskarl.
Session state
~/.config/eterm/session.toml is written by eterm and holds the window size,
the tabs, how each was split, the ratios and the directory each pane was in.
Deleting it gives you a plain window. Only the window a run starts with restores
and records it; a window opened with cmd-n is a new window.
~/.config/eterm/durations.toml is written by eterm too: how long each command
has taken in each directory, the last five successful runs of three seconds or
more. It is what fills the bar under a tab while a known command runs. Deleting
it forgets the times; nothing else depends on it.
A project's layout
A project can say which sessions it wants open, in a .eterm.toml in its root:
[[pane]]
run = "npm run dev"
[[pane]]
run = "npm test -- --watch"
split = "down"
[[pane]]
Each pane is split off the one before it — to the right, or down — and runs
its command; a pane without run is a shell. At most six panes are read.
Nothing runs because the file is there. When a session arrives in the
directory, a strip at the bottom of the window names every command the layout
would run, and Open in a new tab is what runs them. Not now puts it away
for the rest of the window's life, and Open this project's layout in the
command palette brings it back. A file that does not parse, or a run with a
line break in it — two commands wearing one — is not offered at all.