Technical Documentation: gnachman/iTerm2
ℹ️ Provenance: Multi-Tier Ingestion · CodeWiki Reference
Overview (README.md)
About
iTerm2 is a powerful terminal emulator for macOS that brings the terminal into the modern age with features you never knew you always wanted.
Key Features
- tmux Integration - Native iTerm2 windows/tabs replace tmux's text-based interface. Run tmux -CC and tmux windows become real macOS windows. Sessions persist through crashes, SSH disconnects, and even app upgrades. Collaborate by having two people attach to the same session.
- Shell Integration - Deep shell awareness that tracks commands, directories, hostnames, and usernames. Enables click-to-download files via SCP, drag-and-drop uploads, command history per host, recent directories by "frecency," and marks at each prompt.
- AI Chat - Built-in LLM chat window that can optionally interact with terminal contents. Link sessions to get context-aware help, run commands on your behalf, or explain output with annotations.
- Inline Images - Display images (including animated GIFs) directly in the terminal. Use imgcat to view photos, charts, or visual output without leaving your workflow.
- Automatic Profile Switching - Terminal appearance changes automatically based on hostname, username, directory, or running command. SSH to production? Background turns red. Different environments get different visual contexts.
- Dedicated Hotkey Windows - System-wide hotkey summons a terminal that slides down from the top of the screen (or any edge), even over fullscreen apps. Pin it or let it auto-hide.
- Session Restoration - Sessions run in long-lived server processes. If iTerm2 crashes or upgrades, your shells keep running. When iTerm2 restarts, it reconnects to your sessions exactly where you left off.
- Built-in Web Browser - Browser profiles integrate web browsing into iTerm2's window/tab/pane hierarchy. Copy mode, triggers, AI chat, and other terminal features work in browser sessions.
- Configurable Status Bar - Per-session status bar showing git branch, CPU/memory graphs, current directory, hostname, custom interpolated strings, or Python API components.
- Triggers - Regex patterns that fire actions when matched: highlight text, run commands, send notifications, open password manager, set marks, or invoke Python scripts.
- Smart Selection - Quad-click selects semantic objects (URLs, file paths, email addresses, quoted strings). Right-click for context actions. Cmd-click to open.
- Copy Mode - Vim-like keyboard selection. Navigate and select text without touching the mouse. Works with marks to jump between command prompts.
- Instant Replay - Scrub backward through terminal history to see exactly what was on screen at any moment, with timestamps. Perfect for catching fleeting errors.
- Python Scripting API - Full automation and customization via Python. Create custom status bar components, triggers, menu items, or entirely new features.
- Open Quickly - Cmd-Shift-O opens a search across all sessions by tab title, command, hostname, directory, or badge. Navigate large session collections instantly.
Installation
Download
Get the latest version from iterm2.com/downloads
For the bleeding edge without building, try the nightly build.
Build from Source
Note: Development builds may be less stable than official releases.
Prerequisites
- No manual prerequisites.
make setupwill install Homebrew, Xcode,
Rust, and all other dependencies, prompting for confirmation before each privileged step.
Clone
git clone https://github.com/gnachman/iTerm2.gitSetup (first time)
make setupmake setup is interactive and will prompt for confirmation before any privileged or
security-sensitive operation. It performs the following steps:
- Homebrew -- Installs Homebrew via its official install script
if not already present. Prompts before running the installer (which requires sudo). - Xcode -- If no Xcode is selected via
xcode-select, installs
xcodes (via Homebrew) and
aria2 (for faster downloads), then either selects an
existing/Applications/Xcode*.appor downloads the latest Xcode automatically.
Prompts before runningsudo xcode-selectand before accepting the Xcode license. - Rust -- Installs rustup via the official
curl | sh
installer if not already present. Prompts before executing the script. - Homebrew packages -- Installs cmake, pkg-config, automake, perl, and python3 if
missing. Ifbrew link python@3would overwrite existing symlinks, prompts for
confirmation before proceeding. - SF Symbols -- Installs the SF Symbols cask (a
.pkginstaller that requires
sudo). Prompts before attempting the install; continues without it if declined. - Python/Rust packages -- Installs pyobjc (via pip) and cbindgen (via cargo) if not
already present. - Submodules and toolchains -- Initializes git submodules, adds the x86_64 Rust
target, and downloads the Metal toolchain.
To skip all confirmation prompts, use make dangerous-setup instead.
After setup, compile native dependencies and build:
make paranoid-deps # compile OpenSSL, libsixel, libgit2, Sparkle, etc. (sandboxed)
make # build iTerm2Re-run make paranoid-deps whenever your active Xcode version changes -- the file last-xcode-version tracks which version was last used.
If your Xcode version differs from the one committed in last-xcode-version (e.g. you're on an older machine), suppress the noise without committing your local version:
git update-index --skip-worktree last-xcode-versionTo undo: git update-index --no-skip-worktree last-xcode-version
Build
make DevelopmentRun
make runArchitecture
Builds target your native architecture by default. To produce a universal (arm64 + x86_64) binary:
UNIVERSAL=1 make DevelopmentCode signing
Code signing is disabled by default to keep contributor builds simple. To enable it with the project's signing identity:
SIGNED=1 make DevelopmentBuilding in Xcode
If you prefer building from Xcode instead of the command line:
Complete the Clone and Setup steps above.
Configure code signing with your team ID:
bashtools/set_team_id.sh YOUR_TEAM_IDThis script updates
DEVELOPMENT_TEAMin all Xcode project files (iTerm2 and its dependencies like Sparkle, SwiftyMarkdown, etc.) so code signing works with your identity.To find your team ID: Open Keychain Access, find your "Apple Development" or "Developer ID" certificate, and look for the 10-character string in parentheses (e.g., "H7V7XYVQ7D").
No Developer account? Skip this step and select "Sign to Run Locally" in Xcode's Signing & Capabilities tab.
Open
iTerm2.xcodeprojin Xcode.Edit Scheme (Cmd-<) and set Build Configuration to Development.
Press Cmd-R to build and run.
Development
Contributing
We welcome contributions! Please read our contribution guide before submitting pull requests.
Bug Reports & Issues
- File bugs: iterm2.com/bugs
- Issue tracker: GitLab Issues
Note: We use GitLab for issues because it provides better support for attachments.
Resources
| Resource | Link |
|---|---|
| Official Website | iterm2.com |
| Documentation | iterm2.com/documentation |
| Community | iTerm2 Discussions |
| Downloads | iterm2.com/downloads |
License
iTerm2 is distributed under the GPLv3 license.
Support
If you love iTerm2, consider:
- Starring this repository
- Spreading the word
- Sponsoring development
Adding A Shell Integration Checklist (`docs/adding-a-shell-integration-checklist.md`)
App-side checklist: adding a new shell to shell integration
Use this when adding support for any new shell to iTerm2's shell
integration. Throughout, <shell> is the lowercase shell name (as it
appears in ShellIntegrationVersion=...;shell=<shell>), and Xxx is
the corresponding capitalized name used in enum cases and class names.
The most complete precedent is the xonsh work in these two commits;
consult them when a step is unclear:
1bf85d898"Add xonsh as a supported shell for shell integration"1e4ee607b"Add xonsh shell integration"
Line numbers below are approximate and drift over time; grep for the
neighboring symbol.
Prerequisites
- The shell-integration script itself lives in the
iTerm2-shell-integrationsubmodule (a separate repo). It must be
merged there and the script bundled (see "Bundling the script")
before the app-side steps that read from it will work. - Decide how the script is loaded automatically. Shells fall into two
broad shapes:- dotfile shells (bash/zsh/tcsh): the installer appends a
source
line to a dotfile. - autoload shells (fish/xonsh, and similar): the shell auto-sources
scripts from a directory, so no dotfile edit is needed. Injection
is done by setting a marker/config env var (xonsh:XONSHRC
pointing at the script; others may set a directory the shell
autoloads from). Know which env var your shell uses before starting- several steps below key off it.
- dotfile shells (bash/zsh/tcsh): the installer appends a
Bundling the script
-
tools/copy_shell_integration.sh- add acpline copying$SUBMODULE/shell_integration/<shell>toResources/shell_integration/iterm2_shell_integration.<shell>. -
submodules/iTerm2-shell-integration- bump the submodule pointer
to the commit containing the new script. -
iTerm2.xcodeproj/project.pbxproj- additerm2_shell_integration.<shell>as a file reference and into
the Resources build phase of each target that bundles the other
shell scripts (xonsh added one PBXFileReference + three "in
Resources" build files). Usetools/add_file_to_xcodeproj.rb, not
hand-editing. -
sources/ShellIntegration/iTermLatestVersionByShell.h-
DO NOT hand-edit. Its first line is// DO NOT MODIFY - Generated by tools/copy_shell_integration.sh,
andcopy_shell_integration.shregenerates it by greppingShellIntegrationVersion=out of every bundledResources/shell_integration/iterm2_shell_integration.*script:
grep ShellIntegrationVersion= ... | sed -n 's/^.ShellIntegrationVersion=([^;]).shell=([a-z]).*$/ @"\2": @\1,/p'
The@"<shell>"entry appears automatically once the script is
bundled and the generator runs; its value is whatever the script
announces (do not assume 1 - e.g. xonsh reads@2today, not the@1its original commit added, because the script was bumped and
the header re-derived).
Precondition on the script: it MUST emit a token matching the
generator regex, i.e. containShellIntegrationVersion=<N>;shell=<shell>with a lowercase[a-z]*shell name (xonsh does this in a comment:# ShellIntegrationVersion=2;shell=xonsh). If the script omits
that token or names the shell differently, the header entry is
silently dropped and the shell falls back to no version tracking.
Verify the bundled script contains this before relying on the
header.
Enum / shell registry
-
sources/ShellIntegrationInstaller/iTermShellIntegrationInstaller.h
- addiTermShellIntegrationShellXxxto theiTermShellIntegrationShellenum.
(Note: the version headeriTermLatestVersionByShell.his NOT
edited here - it is generated. See "Bundling the script".) -
sources/ShellIntegrationInstaller/iTermShellIntegrationWindowController.m
- (a)iTermShellIntegrationShellString: return@"<shell>"; (b)
the shell-name to enummap(~line 610): add@"<shell>": @(iTermShellIntegrationShellXxx).
Shell version-notification table
-
sources/VT100Screen/VT100ScreenMutableState+TerminalDelegate.mterminalSetShellIntegrationVersion:- update the stale shell-name
comment (~line 2704) to include the new shell. Do NOT add the shell
to the hardcoded critical-updates dict unless it has a version
users must be nagged off of even in "only critical updates" mode;
a brand-new shell should be omitted (its latest version already
comes from the generated header on the non-critical path).
Automatic injection (login shell + SSH)
-
sources/ShellIntegration/ShellIntegrationInjection.swift- (a)
addlocal("iterm2_shell_integration.<shell>")to the resource
list (~line 167); (b) addcase xxx = "<shell>"to theShell
enum and its factory switch; (c) add anXxxShellIntegrationInjectionclass that sets the shell's
marker/config env var so the shell autoloads the script (xonsh
appends the script path toXONSHRC; a directory-autoload shell
sets its autoload-dir env var to a dir containing the script -
confirm that dir actually contains a correctly-named script the
shell will source). INVARIANT:computeModified(env:argv:)must
returnargvUNCHANGED (env-only injection), exactly like the
xonsh class (return (modifiedEnvironment(...), argv)). If it
ever mutates argv, the Conductor empty-argv SSH branch (below) no
longer applies and integration silently fails to load over SSH.
(If your shell genuinely requires argv changes - like bash's--posix- then the Conductor branch must name the shell
explicitly; see that code.) -
sources/SSH/Conductor.swift- two edits:alwaysSupported(~line 2080): add"<shell>"so SSH injection is
offered for that remote login shell.- the comment enumerating env-only-injection shells (~line 2344)
reads "(zsh/fish/xonsh add no argv)". If your shell is purely
env-based, add it to that list. This documents why the empty-argv
branch (~lines 2332-2344) is correct - which holds only if the
injection class honors the argv-unchanged invariant above.
Manual installer UI flow
-
sources/ShellIntegrationInstaller/iTermShellIntegrationWindowController.m
- dotfile shells mostly work through the existing switches; an
autoload shell needs the xonsh-style "no dotfile" special-casing.
Points to cover:shellIntegrationPath-> where the script is written (dotfile
shells:~/.iterm2_shell_integration.<shell>; autoload shells: the
autoload path, e.g. xonsh returns~/.config/xonsh/rc.d/iterm2.xsh;
mind any macOS-vs-Linux config-dir differences your shell has).- utilities: dotfile shells add aliases; PATH-style shells add a PATH
line instead (xonsh:$PATH.insert(...)) and returnnilin the
alias builder'scase ...Xxx:. shell_and/quoting switch (~line 331): addcase ...Xxx:in the
arm matching the shell's&&/quoting behavior.- if autoload:
mkdir -p <autoload dir>step (xonsh addedmkdir -p ~/.config/xonsh/rc.d) in both the string builder
(~line 413) and thereallySendpath (~line 516). launchBashString/exitBashStringsingle-bash special case
(only if the shell needs the same prompt-setup workaround xonsh
did).modifyStartupScriptsAndProceedTo:early-return no-op + the twofinishSendShellCommandsInstallshortcuts (~lines 648, 709, 721)
for autoload shells that skip the dotfile step.
-
sources/ShellIntegrationInstaller/iTermShellIntegrationPasteShellCommandsViewController.m
- (a) update the "not supported" message to list the shell; (b)
for autoload shells, add the "auto-loads ... no dotfile update
needed" step and theisDoneshortcut (xonsh: ~lines 64, 131).
Other shell-list touchpoints
-
sources/API/iTermAPIScriptLauncher.m- add@"<shell>"toknownShells(~line 818) so API scripts run through that login
shell. Only do this if<shell> -c 'echo /tmp/a\ b'prints/tmp/a b: the command is built with POSIX backslash escaping, so
a shell that keeps the backslash (nushell does) splits paths with
spaces and every API script fails to launch. Leave such shells
out; they fall back to/bin/bash. -
sources/Settings/ProfilesGeneralPreferencesViewController.m-
TWO lists here, both updated by the xonsh commit:- the
shellsarray (~line 743, code) that enables "Load shell
integration automatically" for a custom command. - the SSH-command-type reason string (~line 786), currently
@"Requires bash, fish, tcsh, xonsh, or zsh.";(user-visible). Add
the shell in alphabetical order. If you add it only to the array,
the SSH help text will wrongly claim the shell is unsupported.
- the
-
OtherResources/framer.py-get_env_var(~line 596) detects
the shell fromos.environ.get('SHELL'). Two edits:add
<shell>toknown_shells(~line 604).add a
$SHELLoverride mirroring the xonsh one (~lines 601-602),
keyed off the shell's marker env var:textif os.environ.get('<MARKER_ENV_VAR>', ''): user_shell = '<shell>'This is REQUIRED for any non-POSIX / non-login-shell case, not
optional: when the shell is launched as a custom profile command,
over SSH, or any path where it is not the login shell,$SHELL
still points at the user's POSIX login shell (e.g./bin/zsh), sobasename($SHELL)misdetects. xonsh needed this override (keyed offXONSHRC) for exactly this reason. Verify by launching the shell as
a non-login custom command and confirming framer reportsshell=<shell>.
-
sources/AITerm/RemoteCommand.swift- update thegetShellType
help string example list (cosmetic). -
sources/Settings/PreferencePanel.xib- update the "Load shell
integration automatically" tooltip shell list (cosmetic).
Tests & docs
-
ModernTests/ShellIntegrationLiveHarness.swift- add the shell to
every switch in theSupportedShellenum (xonsh touched five):- the enum
caselist (~line 94). - executable candidates (~line 103).
arguments(for:)launch args (~line 108) - route the shell
deliberately, not by falling into a combined arm meant for other
shells.- resource name (~line 124).
sourceCommand(integrationPath:)(~line 128) - the switch is
exhaustive with no default, so it will not compile without an arm
for the new shell. Use the shell's own sourcing syntax (zsh/bash/
fish/tcsh usesource; xonsh usesexecx(open('').read()); others differ - watch for shells
wheresourcerequires a parse-time-constant path).- if the shell's line editor sends a cursor-position report
(CSI 6n) before sourcing (as xonsh's prompt_toolkit does), the
harness must answer it or the shell stalls; xonsh is currentlyXCTSkipped for this, so a new such shell likely needs the same
skip or a CPR responder.
- the enum
-
docs/notes-3.7.txt- add a release-note line, max 50 cols
(xonsh: "xonsh is now a supported shell.").
Not needed / verify only
sources/Settings/Profiles/ITAddressBookMgr.mhas shell-name
mentions in comments only - optionally extend them, no code change.- The quote-escaping commit (
9aff539d4) was a general enabler for
shells whose syntax breaks backslash escaping (xonsh); check whether
any installer command string emitted for the new shell needs similar
treatment.
Adding A Translation (`docs/adding-a-translation.md`)
Adding a Translation to iTerm2
This guide is for translators adding a new UI language to iTerm2. It assumes you
can read the source competently and are fluent in English and your target
language. Everything here has been verified against the current build.
How localization works
iTerm2 uses Apple String Catalogs (.xcstrings files). The source language
is English (en). Any string you do not translate falls back to English
automatically, so a partial translation is safe to ship and safe to grow over
time.
There are two kinds of catalog, and you must translate both to fully
localize the app:
Code strings - one big catalog at
xcstrings/Localizable.xcstrings. Keys
are symbolic (PseudoTerminal.CloseTabsHeading). This covers everything
produced in code: alerts, menus built at runtime, status text, errors, etc.
(The build copies it tosources/Localizable.xcstrings, which is not tracked
by git. Edit thexcstrings/copy, or your changes will not be in your pull
request.)XIB strings - about 70 per-window/per-view catalogs, each named after its
XIB and living in a siblingmul.lproj/directory (e.g.sources/MainMenu/mul.lproj/MainMenu.xcstrings,sources/Settings/mul.lproj/PreferencePanel.xcstrings). Keys are the
interface object's ID and property, e.g.0A4-80-ghh.title. Each key'scommenttells you the class, the English text, and the object ID. There is
alsosources/InfoPlist.xcstringsfor Info.plist strings (permission-prompt
descriptions, app name); seedocs/info-plist-localization.mdfor how those
overlay onto the base Info.plist at runtime.
Every entry stores the English under localizations.en; you add a siblinglocalizations.<lang> with your translation. A translated catalog compiles to<lang>.lproj/<name>.strings (and .stringsdict for plurals) inside the app
bundle at build time, and the app applies it at launch.
Prerequisites
- Xcode (matching the project's required version) and the ability to build:
tools/build.shproduces a Development build. - A working English build first, so you have something to compare against.
Step 1 - Add your language to the project
In Xcode: select the iTerm2 project → Info tab → Localizations → +
→ choose your language (use the Xcode/BCP-47 code, e.g. fr, de, ja,zh-Hant, pt-BR). This makes Xcode's String Catalog editor show a new empty
column for your language in every catalog.
You do not need to touch LOCALIZATION_PREFERS_STRING_CATALOGS or themul.lproj wiring - that is already set up for extraction and is a developer
concern, not yours.
If you prefer to edit the JSON by hand instead of using Xcode's editor (the files
are plain JSON), you simply add a "<lang>" key under a string's"localizations" object; see the shapes below. Either way, keep each file's
existing on-disk formatting. For xcstrings/Localizable.xcstrings, editing the
JSON by hand is strongly preferred: Xcode's editor reorders the whole file, which
turns a small change into a huge, unreviewable diff.
Step 2 - Translate a plain string
In the catalog editor, fill in the cell for your language. If editing JSON, add:
"PseudoTerminal.CloseTabsHeading" : {
"comment" : "Warning heading shown when closing multiple tabs",
"extractionState" : "extracted_with_value",
"localizations" : {
"en" : { "stringUnit" : { "state" : "new", "value" : "Close Tabs" } },
"fr" : { "stringUnit" : { "state" : "translated", "value" : "Fermer les onglets" } }
}
}Rules for every string:
- Read the
comment. It gives context and explains what each placeholder is
("%@ is the profile name", "%1$@ is the shortcut, %2$@ is its purpose"). - Set
statetotranslated. (newmeans untranslated; the build treats it
as English.) - Use your language's punctuation and quotation marks. English uses curly
quotes“ ”and‘ ’; use the correct marks for your language (e.g. « » or
„ "). Do not "fix" the English source to straight quotes. - Preserve escape sequences like
\n(newline) exactly. - No em dashes. This project avoids the em dash (—) in shipped text. Where the
English would use one, use your language's normal punctuation instead (a comma,
colon, parentheses, or a hyphen), whichever reads best.
Step 3 - Format specifiers (`%@`, `%ld`, `%1$@`, …)
Placeholders are substituted at runtime with values (names, counts, paths). You
must keep every specifier, with the same type, and you must not add or
remove any.
%@/%1$@- a string (a name, path, hostname, …).%ld/%d/%lld/%lu- an integer count.%1$@,%2$@, … - positional forms. This is the important one for
translators: positional specifiers let you reorder the inserted values to
fit your language's word order. If the English istext"Plugin has version %1$@ but iTerm2 expects %2$@. Upgrade one or both."your translation may place
%2$@before%1$@if that reads correctly - the
numbers bind each slot to a specific value, so reordering is safe and correct.
A non-positional%@binds to the next value in order and cannot be reordered.Never translate the letters inside a specifier, and never change
%ldto%@(or vice-versa) - that mismatches the value type and can crash.A literal percent sign is written
%%; leave those alone.
Step 4 - Plurals
Some strings change wording based on a number ("1 file" vs "3 files"). English
has two plural categories, one and other. Your language may have more,
and you must supply every category it uses. The categories are defined by the
Unicode CLDR plural rules:
- Chinese, Japanese, Korean, Vietnamese, Thai:
otheronly. - English, German, Dutch, most Romance-ish "singular/plural" languages:
one,other. - Russian, Polish, Czech, Ukrainian, …:
one,few,many,other. - Arabic:
zero,one,two,few,many,other.
Look up your language's categories in the CLDR "Language Plural Rules" tables.
In Xcode's editor a plural string shows the count argument with per-category
rows; add the categories your language needs. In JSON a plural string looks like:
"PseudoTerminal.TabCount" : {
"comment" : "A count of tabs; %lu is the number of tabs",
"localizations" : {
"en" : {
"variations" : { "plural" : {
"one" : { "stringUnit" : { "state" : "translated", "value" : "%lu tab" } },
"other" : { "stringUnit" : { "state" : "translated", "value" : "%lu tabs" } }
} }
},
"ru" : {
"variations" : { "plural" : {
"one" : { "stringUnit" : { "state" : "translated", "value" : "%lu вкладка" } },
"few" : { "stringUnit" : { "state" : "translated", "value" : "%lu вкладки" } },
"many" : { "stringUnit" : { "state" : "translated", "value" : "%lu вкладок" } },
"other" : { "stringUnit" : { "state" : "translated", "value" : "%lu вкладки" } }
} }
}
}
}Notes:
- Keep the count specifier (
%luhere) in each category's value; it is what
gets replaced by the number. It is formatted with your locale's digit grouping
automatically. - A category's value does not have to contain the digit - English
onecould be
just "one tab" - but the runtime still selects it by the number.
Plurals with an extra value (substitutions)
A few strings pluralize on a count and interpolate another value (a path, a
name). These use a substitution: a top-level value like "%2$#@children@"
plus a substitutions block. Example (English):
"ApplyLayout.SplitterTooFewChildren" : {
"comment" : "%1$@ is the path, %2$lld is the child count",
"localizations" : {
"en" : {
"stringUnit" : { "state" : "translated", "value" : "Splitter at %1$@ has %2$#@children@; must have at least 2" },
"substitutions" : {
"children" : {
"argNum" : 2, "formatSpecifier" : "lld",
"variations" : { "plural" : {
"one" : { "stringUnit" : { "state" : "translated", "value" : "%arg child" } },
"other" : { "stringUnit" : { "state" : "translated", "value" : "%arg children" } }
} }
}
}
}
}
}To translate: add your "<lang>" block with the same shape. Translate the
top-level value (you may reorder %1$@ and the %N$#@name@ token) and each
plural category value. Inside a substitution category value, %arg refers to
that substitution's own number; you may also reference other arguments
positionally (%1$@). Add the plural categories your language needs, exactly as
in the plain-plural case.
Step 5 - What NOT to translate
- Format specifiers and the
%%literal (Step 3). - Code operators inside a string. A few help texts contain literal syntax in
backticks or as bare tokens - e.g. the snippet search help lists`tag:`,`title:`,`text:`, the-prefix, and|. Translate the prose
around them; leave the operators exactly as written. The comment says so. - URLs, file paths, escape sequences (
\n). - Product, tool, and protocol names. Keep the app name (iTerm2) and
third-party names untranslated: languages, shells, and tools (Python, tmux,
Git, Zsh, Homebrew, 1Password, LastPass, …) and protocols/formats (SSH, JSON,
UTF-8, OSC, …). Contrast these with iTerm2's own feature names, which are
ordinary words and should be translated: see "Translate feature names" in
the Practical tips below. - The English source (
localizations.en), the keys, thecomment, andextractionState. Only add/edit your own language'slocalizations.<lang>. - Strings that never reach the catalog are already excluded for you - anything a
developer marked "Localization unneeded" (stable identifiers, sentinels used in
comparisons, technical/debug output) is not extracted, so you will not see it.
Step 6 - Verify
Before building the whole app, sanity-check the catalog compiles:
xcrun xcstringstool compile --output-directory /tmp/loc-check xcstrings/Localizable.xcstringsIt must succeed. For a plural entry you can confirm the rule was produced:
plutil -p /tmp/loc-check/<lang>.lproj/Localizable.stringsdictYou should see NSStringFormatSpecTypeKey => NSStringPluralRuleType and your
categories. Do the same for any XIB catalog you edited.
Step 7 - Build and test in your language
Build (tools/build.sh, or Build in Xcode), then run the app forced into your
language:
open Build/Development/iTerm2.app --args -AppleLanguages '(fr)'(Replace fr with your code.) Verify:
- The menu bar renders in your language (the menus are the
MainMenu.xcstrings
catalog). Menu actions are matched internally by stable identifiers, not by
their titles, so translating titles is safe. - Open Settings (Cmd-,) and page through the tabs - those are the many
mul.lprojcatalogs. - Trigger a few alerts (e.g. try to close a window with running jobs) to check
code strings, plurals (with different counts), and word order.
Untranslated strings appear in English; that is expected while a translation is
in progress.
Non-catalog resources (the Python REPL banner)
Almost everything is in the catalogs, with one exception worth knowing about: the
Python REPL banner (the welcome text shown by Scripts > Manage > Python REPL) is
a bundled text file, not a catalog string, because it embeds terminal styling codes,
a hyperlink, and a runnable code sample. To localize it, add a file namedrepl_banner-<localization>.txt next to sources/repl_banner.txt (e.g.repl_banner-pt-BR.txt, using the same localization code as your .lproj), and ask
the maintainer to add it to the app target's "Copy Bundle Resources" build phase (it
is a loose resource, so it cannot be wired up from the catalog). The app picks the
banner matching the current language and falls back to the Englishrepl_banner.txt. Translate the prose but leave the import iterm2 … code sample
and the documentation URL in English.
Practical tips
- Work catalog by catalog.
Localizable.xcstringsis the largest; themul.lprojcatalogs are small and map to one window/view each, so you can see
the context of a whole screen at once. - Length matters. Some strings live in narrow columns, buttons, or the
timestamp margin. Prefer compact wording where the English is compact. - Some labels are deliberately short or arbitrary. A few strings are terse
code-names whose literal meaning does not matter. Keep your rendering as short as
the English rather than translating it literally; substituting a different but
shorter word is fine when the exact word is irrelevant. Honor any comment that
says a string must stay short over a faithful-but-longer phrasing.- Workgroup animal names (
WorkgroupAnimalNames.*) are the clearest case:
they exist only to be short, distinct, one-word default names, so choose for
brevity, not fidelity. The English is always a single short word (2 to 5
letters). If the literal translation of an animal is long or multi-word (for
example German Fledermaus for "Bat", or Russian Летучая мышь), do not
use it. Pick a different, short, single-word animal in your language instead
(a hedgehog, a lynx, whatever is short). Aim to keep every name to roughly the
length of the English, and make sure the names stay unique within the set
(no two keys sharing one translation), since they are used to tell sessions
apart.
- Workgroup animal names (
- Right-to-left languages (Arabic, Hebrew): AppKit mirrors layout
automatically; focus on the text. Keep any leading/trailing spaces that a
comment tells you to preserve. - Consistency: one English string, one translation. The same English text
should get the same translation everywhere it appears, and a given concept
("tab", "pane", "profile", "session") should use a single term across every
catalog. Keep a glossary as you go. Before you finish, scan for any English
string that maps to two different translations and reconcile them - the menu bar
and the settings window, or a context menu and its main-menu twin, are common
places to drift apart. A few divergences are legitimate and should be left: a
word whose form changes with the grammatical gender or number of the noun it
modifies, a term that is a verb in one place and a noun in another, or a common
word used as an arbitrary name in one spot and literally in another. Unify
everything else. If you revise a term after translating, re-check the article
and adjective agreement everywhere it appears. - Translate feature names; do not leave them in English as "terms of art."
iTerm2's feature names (Triggers, Cockpit, Toolbelt, Snippets, Annotations,
Composer, Codecierge, the individual Toolbelt tools, trigger and smart-selection
rule names, …) are ordinary words, not brand names, and should be translated like
any other UI text. Pick one translation per feature name and use it consistently
everywhere it appears (menu, window title, tooltip, settings), so the same feature
reads the same throughout. The only strings that stay English are the ones a
comment explicitly says to keep (brand names like "Patreon"/"GitHub Sponsors",
code operators, format specifiers) and anything a developer marked
"Localization unneeded", which you will not see in the catalog. - Ask the maintainer if a string is ambiguous even after reading its comment,
rather than guessing - a wrong translation is worse than a missing one, which
simply shows English.
Ai Chat Cancellation Design (`docs/ai-chat-cancellation-design.md`)
AI Chat: Cancellation, State, and Persistence Design Issues
This document captures the architectural issues in the AI chat stack that
have surfaced as a recent cluster of bugs around stop / queueing / tool
dispatch, and proposes a phased fix.
The pattern
The bugs are all expressions of the same underlying issue. Catalog:
Silent stop orphans the queue. User sends A, A streams a reply,
user presses Stop, user sends B. B never gets dispatched.- Root:
ChatAgent.stop()calledconversation.stop()→controller.cancel(), which set state to.groundsilently and
never fired the delegate callback. The completion chain fromAITermController→AIConversation.delegate→ChatAgent.fetchCompletion→ChatService.finishTurndied at the
first link. The queue head pointer (A) was never popped; theenqueue(B)sawqueue.count == 2and skippedstartNextTurn. - Patched in
ChatAgent.stop()by routing throughconversation.cancelOutstandingOperation()instead. Pinned bytest_chat_stopThenSend_secondMessageStillDispatches.
- Root:
Stop during a parked tool dispatch is a no-op. Same shape as (1),
but the stop fires while the controller is waiting for the LLM
framework's function-output.parseStreamingResponse(final: true)
setsstate = .groundafter dispatching the function call, sohandle(.cancel)andhandle(.error)are both ignored by the
state machine for the entire duration of the tool execution —
typically seconds.- Patched in
AIConversation.cancelOutstandingOperation()by
falling back to directly firingdelegate.aitermControllerDidCancelOutstandingRequest(controller)
whendelegate.busyis still true after the state machine call.
Pinned bytest_chat_orphanToolResponseAfterStop_doesNotOrphanQueuedMessage.
- Patched in
Orphan tool response silently cancels the next queued turn.
After (1) and (2) are fixed, the in-flightPTYSessioncommand
eventually finishes and publishes a.remoteCommandResponse. The
dispatcher that was supposed to consume it has been cancelled, sohandleRemoteCommandResponsereturns false. The code fell through
tofetchCompletionForRegularMessage, which calledconversation.complete → prepare → cancel()— the silent variant
that setsdelegate.completion = noop. The actively in-flight
queued turn (B) had its completion silently overwritten and never
firedfinishTurn.- Patched in
ChatAgent.fetchCompletionby dropping orphan.remoteCommandResponsemessages withcompletion(nil); return
instead of falling through. Pinned by the same test as (2).
- Patched in
Auto-approved
tool_usenot persisted to chat DB.ChatClient.processRemoteCommandRequestreturnsnilfrom the
broker processor on the.alwayspermission path.ChatBroker.publishinterpretsnilas a full squelch — skips both
subscriber delivery andlistModel.append. Thetool_userecord
carrying the LLM'sfunction_call(with itstoolu_…id) never
lands in the DB. For the current turn that's fine becauseAIConversation.messagesis in-memory; but when the next turn
dequeues from the queue,ChatAgent.load(messages: history)
reconstructs from DB, sees an orphantool_resultwith no
precedingtool_use, and Anthropic 400s.- Patched in
ChatAgent.translateby synthesizing the missingtool_usewhenever atool_resultreferences a request id that
wasn't persisted. Pinned bytest_chat_squelchedToolUseHistoryDoesNotBreakNextTurn.
- Patched in
dropLast()assumes the wrong invariant.ChatService.startNextTurnbuilds B's history asArray(self.messages(chatID:).dropLast()), on the assumption that
the last DB row is the in-flight user message. With B typed during
A's tool round-trip, the DB ends up persistence-ordered as[A, B, tool_use(if not squelched), tool_result, A_reply]. The
last row isA_reply—dropLastdrops it. So B's history is
missing A's full turn outcome (A_reply), and B itself is included
in history (then re-added viaconversation.add(userAIMessage)
infetchCompletionForRegularMessage, duplicating it on the wire).- Not yet fixed. The current
translate+enforceToolUseAdjacency
hacks paper over the worst symptoms but the structural assumption
is wrong.
- Not yet fixed. The current
Why these are all the same bug
Four observations:
(a) Four independent representations of "in flight"
| Layer | State for "a turn is running" |
|---|---|
AITermController |
state: State enum + cancellation token |
AIConversation |
delegate.busy boolean |
ChatAgent |
pendingRemoteCommands: [UUID: PendingRemoteCommand] + conversation.busy |
ChatService |
pendingMessages: [String: [Message]] |
These four states evolve independently. The bugs are all
"layer X thinks the turn is done, layer Y thinks it's still running" —
or the reverse.
(b) Silent cancellation paths
controller.cancel() (AITerm.swift:205), AIConversation.cancel() inprepare (AIConversation.swift:222), and the previousAIConversation.stop() (line 306) all set state to clean and
overwrite delegate.completion = noop. They do not fire the delegate
callback. The outer completion chain dies silently.
The recently-fixed bugs are all silent-cancel paths leaking into
visible behavior. There may be more we haven't hit yet:
delegate.streaming = nilincancel()could orphan a partial
streaming reply.delegate.createVectorStoreCompletion = niletc. incancel()could
orphan a vector-store creation in flight.
(c) State-machine dead zones
.ground ignores every event (AITerm.swift:227-229). But the
controller can sit in .ground for arbitrary wall-clock time while
the function-dispatch layer is waiting for a tool result. Cancellation,
errors, even unexpected web responses during that window all evaporate.
The state machine has no .awaitingFunctionOutput state. The handoff
from parseStreamingResponse(final: true) to doFunctionCall
implicitly uses .ground as that state, but .ground's catch-all
ignore behavior is wrong for it.
(d) Three things conflated into one stream
- The LLM conversation — strict ordering: alternating user/assistant
with tool_use immediately followed by tool_result. Used to build the
API request. - The chat history — what the user sees in bubble UI, persisted to
SQLite. Lossier and more permissive than the LLM conversation. - The dispatch queue — pending user messages waiting their turn.
All three live in the same ChatBroker stream, in the same SQLite
table, with the same ordering. When B is typed during A's in-flight,
B is immediately persisted (broker.publish → listModel.append) and
added to ChatService's pendingMessages simultaneously. The DB ends
up persistence-ordered, which doesn't match LLM-required ordering
when turns interleave.
The LLM conversation is reconstructed from chat history at everyfetchCompletion call (ChatAgent.load(messages: history)). That
reconstruction has accumulated workarounds:
enforceToolUseAdjacencyinAnthropicRequestBuilderreorders
tool_use/tool_result pairs.translateinChatAgentsynthesizes orphanfunction_output
fillers (line 340-356, for tool_use without tool_result, "iTerm2
quit mid-tool-call").translatenow also synthesizes orphanfunction_callfillers
(the recent patch for bug 4).
Each of these is a partial fix for "DB persistence order is not LLM
conversation order."
Proposed fix
Four pieces, in roughly increasing risk:
Phase 1 — Eliminate silent cancellation
Make every cancellation that goes through the controller / conversation
fire the delegate callback. Specifically:
In
AITermController.handle(event:)for.groundstate: ifdelegate.busy(orcancellation != nil),.cancelfiresaitermControllerDidCancelOutstandingRequestand.errorfiresdidFailWithError..groundstops being a black hole.AITermController.cancel()(the silent one used byAIConversation.cancel()) gets renamed totearDown()and its
contract documented as "no callbacks; only for teardown after the
outer caller has already collected what it needs." All current
callers audited: theprepare()site is the suspect one — it's
used to clean up before starting a freshconversation.complete,
but the silent overwrite ofdelegate.completion = nooporphans
any genuinely-in-flight prior call. Replace withcancelOutstandingOperation()so the prior call's completion fires
with.failure(PendingCommandCanceled)and ChatAgent's existing
handler does the right thing.AIConversation.stop()was already changed to usecancelOutstandingOperation(). Remove the now-unused silent
variant.
This is a tactical change, contained to two files
(AITerm.swift + AIConversation.swift). Low risk. Directly
eliminates the bug class described in (b).
Phase 2 — Real state for function dispatch
Add .awaitingFunctionOutput to AITermController.State. The
transition from parseStreamingResponse(final: true) after afunction_call goes to that state instead of .ground. The
transition out happens when doFunctionCall's impl.invoke
completion fires (either .success(output) → .querySent via a new
HTTP request, or .failure(error) → state machine processes the
error).
In .awaitingFunctionOutput, .cancel and .error both fire the
delegate. .word and .webResponse from the original HTTP request
are ignored (the request is done).
Eliminates bug class (c). Localized to AITermController. Combined
with Phase 1, makes the existing state of the code self-consistent.
Phase 3 — Separate dispatch queue from chat persistence
User typing lands in ChatService.pendingMessages only. Persistence
to the DB and broker delivery happen at dispatch time, in the order
the LLM will see them.
Concretely:
- New API:
ChatService.acceptUserMessage(message)adds to
pendingMessages without broker.publish. - UI (ChatViewController) calls this instead of
broker.publishfor
user-typed messages. startNextTurncallsbroker.publishon the dequeued message
first (which persists + delivers to UI subscribers), then
dispatches.
Consequences:
dropLast()becomes well-defined ("the last DB row IS the in-flight
user message," by construction).translatedoesn't need orphan-synthesis hacks; the chat history
matches LLM-required ordering by construction.enforceToolUseAdjacencybecomes unnecessary.- The
ChatClient.processRemoteCommandRequestsquelch-vs-persist
conflict goes away:ChatAgentitself creates and persists the.remoteCommandRequestat the right moment in the LLM-history
order, so there's no need for a separate "show it in UI" path that
the broker processor was conflating with persistence.
Bigger change. Estimated week of work. Eliminates bug class (d).
Phase 4 — Structured concurrency for turns
Each turn becomes a Task. ChatAgent.fetchCompletion becomes anasync function. Cancellation propagates through the task hierarchy.
The four-layer callback chain collapses into one async function
where each await is a natural cancellation point.
Eliminates bug class (a). Biggest change. Probably 2-3 weeks of
careful refactoring with the live harness tests as the safety net.
Would also unlock substantial readability improvements: the current
ChatAgent has 1000+ lines partly because of the manual completion
plumbing.
Recommended sequencing
Phase 1 + Phase 2 together. They're contained to two files, they
eliminate the entire silent-cancel/dead-zone bug class, and they make
the rest of the code base self-consistent. Ship the live tests we
already have as the regression suite.
Then Phase 3 if the data-model conflation continues to produce
bugs (it will).
Phase 4 is the long-term cleanup. Don't block on it.
What this document is for
These bugs are converging on the same root cause faster than we can
patch the symptoms. This document is the place where the next person
(or the same person on a different day) can read the shape of the
problem instead of just the catalog of individual patches.
If you find a fifth bug that looks like one of the patterns above
(silent cancel, state-machine dead zone, persistence-vs-LLM-order,
four-state-machines-out-of-sync), update this doc with the example
and resist the urge to ship another targeted patch.
Ai Live Test Cassettes (`docs/ai-live-test-cassettes.md`)
AI Live Test Cassettes: Record and Playback
The live AI harness (tools/run_ai_live.sh, driving AILiveHarness in the
ModernTests bundle) exercises end-to-end round-trips against real vendor
APIs. That costs real money on every run. The cassette layer lets those
same tests replay recorded responses instead of hitting the network.
It rests on one assumption: a vendor returns the same answer for the same
input. So we only need to spend money when our request would differ from
one we have already recorded. A request that canonicalizes to a known
cassette is served from disk; a request that differs is sent live (and
recorded for next time).
This is a record/replay (VCR/cassette) scheme, not a vendor-conformance
test. A replayed run validates our request builders and response parsers
against a frozen answer. It cannot catch vendor-side drift (a changed
response shape, a new rejection). The pure-live path remains the drift
detector; keep running it on a schedule or before a release. See the
caveats at the end.
The four modes
The mode is set by ITERM2_AI_LIVE_CASSETTE_MODE before invokingtools/run_ai_live.sh, which forwards it into the harness config asCASSETTE_MODE. When unset, nothing changes: the harness behaves exactly
as it did before cassettes existed.
| Mode | On a cassette hit | On a cassette miss | Touches the network |
|---|---|---|---|
off |
(no interception) | (no interception) | Always (pure live) |
auto |
Replay from disk | Go live, then record the response | Only on a miss |
replay |
Replay from disk | Fail offline with a CASSETTE MISS |
Never |
record |
(ignored) Go live | Go live | Always; (over)writes every cassette |
off(default when unset). No interceptor is installed. Pure live,
the historical behavior. Use this to capture or refresh genuine vendor
behavior, or when you specifically want to test against the real API.auto. The everyday cheap mode. The first time a given request is
seen it goes live and the response is saved; every subsequent run with
the same input replays for free. Existing cassettes are never
overwritten in this mode, so a recorded response stays put until you
delete it or refresh withrecord.replay. The strict, money-free mode for CI. A hit replays; a miss
does not fall through to the network. Instead the call fails with an
error whose reason beginsCASSETTE MISS, which fails the test and
prints the canonical request so you can see what changed. A miss here
means one of two things: you legitimately changed the request builder
(re-record), or a real regression. The mode refuses to paper over either
by silently spending money.record. Deliberate refresh. Every request goes live and its
cassette is overwritten, regardless of what was on disk. Use this after a
request-builder change to regenerate the affected cassettes, then review
the diff before committing.
Why `replay` fails instead of falling back
A cassette miss is ambiguous. It can mean "the request builder changed and
this is expected" or "something is broken / the vendor drifted." The cache
cannot tell these apart. If a miss silently went live and recorded, a run
would go green by recording whatever the vendor now returns, laundering a
behavior change into a new fixture that nobody reviewed. replay mode
forbids that: misses are loud and offline. Refreshing a cassette is always
an explicit auto/record step a human reviews, mirroring how the
existing SafetyRefusalFixtures are opt-in (ITERM2_AI_LIVE_REFUSAL).
Telling cached from live in the logs
Every request prints one [cassette] line to the test output saying what
happened, so you never have to guess whether a value came from disk or the
wire:
[cassette] HIT (replay) key=1931d8e1dfbd POST https://.../v1/chat
[cassette] MISS (auto, going live) key=c80781daf161 POST https://.../v1/chat [stream]
[cassette] RECORD (auto) key=c80781daf161 (7 chunks)
[cassette] LIVE (record) POST https://.../v1/chat
[cassette] MISS (replay, FAILING) key=3f56...648c POST https://.../v1/chat- HIT means the response was replayed from a cassette: no network, no
money. - MISS (auto, going live) then RECORD means the request was not
cached, so it went live and was saved. - LIVE (record) means
recordmode forced a live call to refresh. - MISS (replay, FAILING) means strict mode refused to go live; the test
fails and the full canonical request is printed beneath the line. - REWRITE appears instead of RECORD when
recordoverwrites an existing
cassette.
The key shown is the cassette filename (truncated for hits/records, full on
a failing miss so you can find or diff <key>.json). URLs are scrubbed of
secrets. Replayed calls also produce no AIChatWireLogger entry and showelapsed near zero in their capture JSON, but the [cassette] line is the
signal to read.
How a request is matched
The cache key is not the raw request bytes. Requests carry per-run
noise that would otherwise miss on every run (and so never save anything).
The canonicalizer (AICassetteCanonicalizer) strips that noise before
hashing, so two runs that build the same logical request produce the same
key:
- API keys. We know the secret values from the harness config, so each
is replaced with<SECRET>wherever it appears: theAuthorization/x-api-keyheaders and Gemini's?key=URL parameter. Two developers
with different keys hash identically. - UUIDs. Every UUID is replaced with a positional placeholder
(,, ...) numbered by first appearance. This collapses
locally minted ids and the random multipart boundary, while keeping two
genuinely distinct UUIDs distinct. - Multipart boundary. A multipart upload's boundary is per-run random.
It is echoed in theContent-Typeheader (boundary=...), so the
canonicalizer reads that literal value and replaces it everywhere (header
and body), independent of whether it is UUID-shaped. For a binary
(.bytes) body the boundary bytes are replaced and the result is
digested; the file payload itself is a deterministic fixture, so the
digest is stable. - JSON key ordering. Swift dictionary ordering is not stable across
runs. String bodies that parse as JSON are re-serialized with sorted
keys, and headers are sorted by name, before hashing.
The key is the SHA256 of the resulting canonical text. The cassette file is
named <key>.json and also stores the canonical request text, so areplay miss is diffable against what you expected.
Recorded responses are scrubbed of secret values too (in case a vendor ever
echoes a key in an error message), which is why cassettes are safe to
commit.
Where cassettes live
By default, ModernTests/Resources/AICassettes/. Override withITERM2_AI_LIVE_CASSETTE_DIR (forwarded as CASSETTE_DIR). Because they
are scrubbed, the intent is to commit them: once recorded, CI and local dev
replay them for free and only the live drift suite spends money.
Transient failures (HTTP 429, the 5xx capacity codes, RESOURCE_EXHAUSTED,UNAVAILABLE, timeouts) are not recorded, so a capacity blip during a
recording run does not poison a cassette with a failure that would then
replay forever.
Typical workflows
Populate cassettes once with real keys, then replay for free:
# Record everything the smoke suite touches.
ITERM2_AI_LIVE_CASSETTE_MODE=auto tools/run_ai_live.sh smoke
git add ModernTests/Resources/AICassettes
git commit -m "Record AI smoke cassettes"
# From now on, free and offline.
ITERM2_AI_LIVE_CASSETTE_MODE=replay tools/run_ai_live.sh smokeRefresh after changing a request builder:
ITERM2_AI_LIVE_CASSETTE_MODE=record tools/run_ai_live.sh attachmentMatrix
git diff ModernTests/Resources/AICassettes # review before committingRunning `replay` in CI without real keys
The harness skips a vendor whose API key is absent, and the request builder
embeds the key into the request. So replay still needs a key per vendor
you want to exercise, but the value is irrelevant: any non-empty placeholder
works because canonicalization neutralizes whatever it is. Set dummy keys in
CI:
OPENAI_API_KEY=sk-test ANTHROPIC_API_KEY=sk-test \
GEMINI_API_KEY=test DEEPSEEK_API_KEY=sk-test \
ITERM2_AI_LIVE_CASSETTE_MODE=replay tools/run_ai_live.shHow it hooks in
The interceptor sits at the single network chokepoint,iTermAIClient.request(webRequest:stream:completion:). It mirrors the
existing liveObserver: a static hook (requestInterceptor) that is
always compiled in but only set by the test harness. When it returns a
delivery, request() replays the recorded stream chunks and final
response/error and fires the same observer and completion a live call
would, so downstream parsers and AILiveDriver see an identical sequence
of events. When it returns nil, the call proceeds live as normal. Nothing
in the shipping app sets the hook.
- Production seam:
sources/AITerm/AIPluginClient.swift
(requestInterceptor,ReplayDelivery, the short-circuit inrequest). - Cassette logic:
AILiveHarness/AICassette.swift(AICassetteCanonicalizer,AICassetteStore,AICassetteSession). - Wiring:
AILiveHarness/AILiveDriver.swiftinstalls the interceptor next
toliveObserverinrunOnceand records fromconsume. - Coverage:
ModernTests/AICassetteCanonicalizerTests.swift(keying) andModernTests/AICassetteReplayTests.swift(replay through the production
seam). Both run offline and are not gated by the live config.
Caveats
- Replay validates our code, not the vendor's. It cannot catch a changed
response shape or a new rejection. Keep a real-network tier (off)
running on a schedule or before releases to catch drift, especially the
attachment matrix, whose whole purpose is to fail loudly when vendor
accept/reject behavior changes. - A cassette is only as current as its last recording. If a vendor changes
behavior, replayed tests stay green until you re-record against the real
API.
Ai Model Catalog (`docs/ai-model-catalog.md`)
AI Model Catalog
iTerm2's list of built-in AI models (context window, capabilities, the API/wire
protocol to use, and the recommended "latest" model per vendor) is data-driven.
Instead of hardcoded constants it is loaded from a JSON catalog that ships in the
app bundle and can be refreshed at runtime by downloading a newer, signed copy.
This lets new models reach users without an app update.
This document covers the moving parts, the runtime refresh + signing scheme, and
the process for publishing an update.
Files and types
In this repo:
OtherResources/ai-models.json- the bundled catalog (source of truth).
Schema: a top-level{ "version": Int, "models": [ ... ] }. Each model entry
is described in the header ofAIModelCatalog.swiftand the "ADDING A NEW
MODEL" checklist inAIMetadata.swift.sources/AITerm/AIModelCatalog.swift- loads the catalog. A privateCodable
DTO decodes the JSON and maps it into the existingAIMetadata.Model. It
loads both the bundled snapshot and any downloaded cache and uses whichever
has the higherversion. Entries whoseapi/vendorstring the running app
does not understand are skipped (fail-safe), not fatal.sources/AITerm/AIModelCatalogUpdater.swift- the runtime refresher (see
below).sources/AITerm/AIMetadata.swift- unchanged public surface; itsmodels
array andrecommended<Vendor>Model/alternate<Vendor>Modelsaccessors now
read fromAIModelCatalog.OtherResources/rsa_pub.pem- the RSA public key used to verify downloaded
catalogs. Same key pair as the Python runtime download.tools/copy_models.sh- the publish script (see "Publishing").
Served from the website (iterm2.com/downloads/ai/):
models.json- the catalog payload. This isai-models.jsonfrom this repo,
copied verbatim. Stable URL.manifest.json- the signed manifest the app actually fetches. A JSON array
of entries{ version, url, signature, minimum_ai_version?, maximum_ai_version? }.
The catalog payload and the manifest are two different files because they have
different shapes: the payload is an object ({version, models}), the manifest is
an array of download candidates. The app's aiModelCatalogURL advanced setting
points at manifest.json.
Three independent version numbers
Do not conflate these:
- Catalog
version(inai-models.json/ each manifest entry) - a
monotonic data revision. The app installs a downloaded catalog only when its
version is greater than the bundled/cached one, and the manifest entry'sversionmust equal the version inside the payload it points at. AIModelCatalog.appCatalogCompatibilityVersion- a monotonic integer
baked into the app describing what the app's AI code can represent. Bump it
only for a change an older app could not consume gracefully (a newiTermAIAPIserializer, a newModel.Feature, or a newVectorStoreConfig
value that alters how a model is represented or selected). A purely additive,
optional field older apps ignore when decoding (likeeconomyModel) does not
warrant a bump - a server catalog using it should keepminimum_ai_version
low so older apps still adopt it and just skip the optimization. The manifest
gates each candidate on an integer[minimum_ai_version, maximum_ai_version]
range against this value. Using an integer (rather than parsing the app's
version string) is nightly/beta/adhoc-proof.- App version string (
CFBundleShortVersionString) - not used by the
catalog machinery at all.
A new model on an already-supported serializer is a pure data update. A new
wire protocol still needs an app build; a catalog that references it setsminimum_ai_version to the compatibility version where that serializer landed,
so older apps skip that entry and fall back to the highest one they can consume.
Economy models
A model entry may carry an optional economyModel naming a cheaper same-vendor
model (e.g. claude-opus-4-8 -> claude-haiku-4-5). It is for high-frequency,
low-stakes work where the primary model is overkill and too expensive to run
repeatedly - specifically the orchestration screen-watch poller
(ScreenWatchPoller), which asks a model every few seconds whether a session has
reached a described condition.
AIMetadata.economyModel(for:) resolves a model to its economy alternative
(returning nil when there is none). It works in both "use recommended" mode and
manual mode: if the passed model carries no pointer (settings-built models do
not), it falls back to the catalog entry of the same name.
Crucially, the resolved economy model preserves the caller's transport (itsurl and api, hence which host the API key is sent to) and swaps only the
model identity and size limits. A manual-mode user may point a catalog-named
model at a custom base URL (corporate proxy / gateway); resolving to the economy
entry's own public endpoint would bypass that proxy and leak the API key to the
public vendor host. Same-vendor guarantees the economy model speaks the same
protocol, so forcing it onto the configured endpoint is safe.
Because of this, ScreenWatchPoller sets AIConversation.modelOverride (a full
model used verbatim), not AIConversation.model (a name thatAIConversation.complete re-resolves against the public catalog). An absent
economy model leaves the poller on the configured chat model.
Runtime refresh
AIModelCatalogUpdater (a singleton, @objc(iTermAIModelCatalogUpdater)) runs
silently, with no UI beyond the one-time consent prompt:
- Download the manifest from
aiModelCatalogURL. - Filter entries to those whose
[minimum_ai_version, maximum_ai_version]range
admits this build and whoseversionis newer than what is already loaded,
ordered highest-version-first. - Download the top candidate's payload.
- Verify the payload's RSA signature (
iTermSignatureVerifier,
RSA-PKCS#1-v1.5 over SHA-256) against the bundledrsa_pub.pem. - Validate that it decodes to at least one usable model, satisfies the catalog
invariants (seeAIModelCatalog.validate: everyeconomyModelresolves to a
same-vendor non-self entry; at most one recommended per vendor), and that its
version matches the manifest entry, then atomically replace the cached
catalog.
If a candidate fails at any of steps 3-5, the updater falls through to the next
candidate, so one broken top entry (dead URL, botched signing) doesn't stall
updates when an older-but-still-newer good entry exists in the same manifest. The
refreshed catalog takes effect on the next launch. On any failure the previous
cached/bundled catalog is left untouched.
When it runs
performPeriodicCheck() is invoked at app launch (applicationDidFinishLaunching)
and again the moment AI is turned on (via the iTermSecureUserDefaults.didChange
notification for the enableAI key), so a freshly-enabled user does not wait for
a relaunch. It is rate-limited to once per day
(iTermPersistentRateLimitedUpdate), and self-gating, so calling it from both
places is idempotent.
Gating and consent
Downloading contacts the network, which changes the privacy model, soperformPeriodicCheck applies three gates in order:
- AI must be fully enabled: allowed in advanced settings and in the secure
setting (both covered byiTermAITermGatekeeper.allowed) and the plugin
installed (iTermAITermGatekeeper.pluginInstalled()). If AI is not set up we
never download and never even ask. - A non-empty, valid
aiModelCatalogURL: clearing it disables checking
entirely, as the advanced-setting help promises. This is checked BEFORE
consent so a cleared URL also suppresses the consent prompt (rather than
asking permission for a download that can never happen). A non-empty URL does
not by itself enable anything; the other gates still apply. - One-time consent: a tri-state
iTermUserDefaults.aiModelCatalogUpdateConsent(.unknown/.granted/.denied, stored under aNoSynckey). On first eligible check we present a
modal; the answer is remembered so we never ask again. A dismissal leaves it.unknownso we ask again rather than silently disabling updates. An
in-flight guard prevents stacking a second modal if a queued check re-enters
while the prompt is open.
Signing
The signature is a detached RSA signature over the raw bytes of the payload,
base64-encoded:
openssl dgst -sha256 -sign "$RSA_PRIV" -out models.sig models.json
openssl base64 -A -in models.sig # the signature string
# self-check against the shipped public key:
openssl dgst -sha256 -verify OtherResources/rsa_pub.pem -signature models.sig models.json
# -> "Verified OK"$RSA_PRIV is the private key matching OtherResources/rsa_pub.pem (the Python
runtime signing key; not in the repo). The signature goes in the manifest entry'ssignature field, not in the payload. The bundled ai-models.json is not
signature-checked - its integrity comes from the app's own code signature; only
downloaded payloads are verified.
Gotchas:
- Sign the exact bytes you serve. Any reformatting after signing breaks the
digest. Sign last. - The manifest entry's
versionmust equal the top-levelversioninside the
payload, or the app rejects the download. - The manifest itself is unsigned; that is fine, because nothing it points at can
be installed without a valid payload signature, and it is fetched over HTTPS.
Publishing
tools/copy_models.sh bridges this repo (source of truth) and the website repo
(~/iterm2-website, which serves iterm2.com). It copies the payload, signs it,
updates the manifest, and pushes both live via scp to the bryan host (the
same mechanism as update-patrons.sh).
Full release process (the script automates steps 4-5):
- Edit
OtherResources/ai-models.json. Capture any new refusal fixtures the
tests require (see the "ADDING A NEW MODEL" checklist inAIMetadata.swift). - Bump the top-level
version. Mandatory: the app only installs a higher
version, and it must match the manifest entry. - Build and ship the app so the bundled snapshot matches (an app upgrade always
wins over a stale cache of the same-or-lower version). - Run the publish script:It writestext
RSA_PRIV=/path/to/rsa_priv.pem tools/copy_models.sh~/iterm2-website/downloads/ai/models.jsonand merges an entry intomanifest.json(replacing this version's entry, keeping others), self-verifies
the signature againstrsa_pub.pem,scps both files tobryan:iterm2.com/downloads/ai/, and opens the Cloudflare cache page so you
can purge the two URLs. SetAI_MIN_VERSION=to raise the entry'sminimum_ai_versionwhen the catalog needs a newer app. - Commit the website repo (
models.json+manifest.json) so the deploy is
reproducible.
Because both files sit at stable URLs, a CDN can serve stale copies; purge the
Cloudflare cache for models.json and manifest.json after pushing. If a stale
payload is briefly served against a newer manifest, the version-mismatch check
rejects it safely (no update until the cache clears) rather than installing
anything wrong.
Bidi Selection Direction (`docs/bidi-selection-direction.md`)
Bidi (RTL) selection direction: discussion, open questions, next steps
Working notes from reviewing PR #709 ("Right-to-left (Persian/Arabic/Hebrew)
rendering, selection, and copy", author @nuved). Recorded 2026-08-06.
This file captures the design discussion so it is not lost while we wait on the
author. It is not a spec; it records where we are and why.
Context
PR #709 adds RTL rendering (mirror pairs, guillemets as-typed on both the
CoreGraphics and Metal renderers), scrollback/scrollRect propagation of the
per-line RTL annotation, and a rework of selection and copy so that the
selection is stored and reasoned about in logical (source-cell) coordinates.
Everything is gated behind the existing Bidi preference and is a no-op with it
off.
The rendering and copy-order work is good and worth keeping. The open question
is the mouse selection model.
The core question: visual vs logical mouse selection
The distinguishing characteristic of logical selection in mixed-direction text
is discontiguousness: a logical range can render as several disjoint
rectangles on screen, and its copy includes everything logically between the
endpoints. Visual selection follows the pointer, stays contiguous on screen,
and never goes discontiguous (word and line selection included, since each is a
single contiguous span).
We wanted to "match OS convention unless the OS is lazily doing the wrong
thing." The complication we discovered: macOS is not internally consistent,
so there is no single convention to defer to.
What we tested (empirical, not assumed)
Test line: سلام Hello دنیا (on-screen order left to right: donya Hello salaam). Probe: drag-select from the middle of Hello (starting at the secondl) rightward into the Persian word to its right, then copy.
- TextEdit (native NSTextView): VISUAL selection + logical-order copy.
Highlight follows the pointer and stays contiguous. Copy serializes the
glyphs actually swept into reading order, givingسلام llo. TheHethat was
not dragged over is excluded. - Safari, Chrome, Firefox (all three tested): LOGICAL selection.
Selection is a logical range between the endpoints, highlight can jump and go
discontiguous, and copy returns the whole logical interval, givingسلام Hello(includes theHethat was not swept).
So the split is: native macOS text (TextEdit) on one side, the entire browser
platform on the other. PR #709 currently implements the browser/logical
model. Both are legitimate, deliberate conventions; the PR's choice is
defensible, not a bug.
Two earlier claims were corrected by testing: browsers do NOT do visual
selection (all three do logical), so any "every browser does X" framing was
wrong. Only assert what was tested.
Where the maintainer (non-RTL) currently leans
Lean, held loosely: visual for the mouse, because iTerm is a native Mac app
whose selection has always been visual/column-based, its closest analog is the
native text view rather than a web document, and "what I dragged is what I get"
is least surprising for terminal use (commands, paths, output). Personal
reaction: the browser/logical behavior is hard to use because the highlight
stops tracking the mouse.
Important caveat: this is a non-expert opinion. The author reads and writes RTL;
we asked for their analysis rather than imposing a preference.
copy mode as the home for logical selection
Regardless of the mouse decision: iTerm's copy mode is our keyboard-driven
selection and the natural analog of shift-arrow, where macOS itself does
logical, discontiguous selection even in native text views. So if we land on
visual for the mouse, logical selection still has a clean home in copy mode
rather than being lost.
Open questions (posted to the author)
Posted at https://github.com/gnachman/iTerm2/pull/709 (issuecomment-5198952840):
- How did they analyze the tradeoff? Was the browser/logical model chosen
deliberately, or did it fall out of storing the selection logically for the
copy fix? - As an RTL user, which do they reach for and expect day to day, and does it
change by context (prose vs a command or path in a terminal)? - Is there something better about the browser/logical model for RTL
specifically that a non-RTL user would miss?
Implementation issues parked until direction is settled
These came out of the review and are independent of the visual-vs-logical
decision. Held back so we do not bury the author before aligning on direction.
- Metal renderer selected fg/bg coordinate mismatch (real bug, fix
regardless). Insources/MetalRenderer/Glue/iTermMetalPerFrameState.mthe
selected background at line 1424 tests the logical cell index
(containsIndex:logicalX, correct), but the selected foreground at line 1702
still tests the visual column (containsIndex:visualX). On a reordered line
these are different cells, so a glyph can get the selected background with the
unselected foreground color and effectively disappear (observed on screen).
Lines 1720 (annotations,visualX) and 1983 (bgrle->origin, visual) have
the same mismatch. The CoreGraphics path is consistent (derives both from the
same logicalrun->selected), so toggling off the GPU renderer makes the
glyphs reappear, which confirms it is Metal-specific. - Missed visual-to-logical conversions feeding the logical mouse selection.
Several selection entry points still pass raw visual coordinates into the now
logical selection model: shift-click-extend and three-finger drag inPTYMouseHandler.m,findOnPageSelectRange:inPTYTextView.m(stores a
visual range viavisualRangeForLogical:into a logical selection),
context-menu copy of a smart-selection action iniTermTextViewContextMenuHelper.m(passesaction.visualRangeto the now
logical extractor), Look Up / force-touch (showDefinitionForWordAt:inPTYTextView+ARC.m), and right-click / pointer-gesture smart selection iniTermURLActionHelper.m. Most of these dissolve if the mouse path stays
visual, since they only exist to feed a logical mouse selection. - Supplementary-plane strong-RTL detection.
firstStrongIsRTLinsources/Drawing/BidiDisplayInfo.swiftwalks UTF-16 units and skips surrogate
halves, so non-BMP strong-RTL characters (Adlam, Hanifi Rohingya, etc.) are
ignored and paragraph direction is misdetected. Gated behind the default-offdetectParagraphDirectionadvanced setting, so low impact, but a regression
vs therangeOfCharacter(from:)code it replaced. - Pre-existing typo (not introduced by this PR).
iTermAttributedStringBuilder.mhasbidiLUT[i - i](always index 0) wherebidiLUT[i - 1]was intended, misplacing spacing combining marks on bidi
lines. From the original GPU-RTL commit, worth a one-character fix while in
the area. - Conventions. The diff adds em dashes to comments and at least one
user-visible string (thestripZeroWidthFormatCharactersOnPasteadvanced
setting description), which the em-dash rule prohibits.
Next steps
- Wait for the author's analysis on the mouse selection model.
- Fix the Metal selected fg/bg coordinate mismatch regardless of direction (it
is a bug under any model). - If we land on visual for the mouse: keep the reading-order copy
serialization, revert the mouse selection/highlight to visual columns (where
iTerm already was), and let copy mode be where logical selection lives. This
also removes the need for most of the missed conversions above. - If we land on logical for the mouse: finish the conversions at every
selection entry point so the model is consistent. - Keep regardless of direction: the mirror-pair rendering, guillemets as-typed,
reading-order copy extraction, and scrollback/scrollRect RTL propagation.
Manual test artifacts (local, not committed)
~/bidi-test.txt: mixed-direction lines tocatin a terminal build with the
Bidi pref on.~/bidi-safari-test.html: same lines in a browser with a live readout of what
Cmd-C would copy (selection string plus code-point breakdown), used to compare
TextEdit against the browsers.
Bumping The Deployment Target (`docs/bumping-the-deployment-target.md`)
Bumping the macOS deployment target
A repeatable procedure for raising iTerm2's minimum macOS version. Written
after the macOS 12 -> 13 bump; substitute the new floor for "13.0" below.
The work spans four kinds of place: the app's own Xcode targets, the
Makefile that builds the native/vendored libraries, the git submodules we
vend, and the local Swift packages. Then everything is rebuilt and the
deprecations that the new floor surfaces are fixed.
0. Pick the value
Use N.0 (e.g. 13.0). Historically the app targets drifted to .4
values (12.4); standardize on .0 when you bump. macOS 12 is the last
release whose bundled Python runtime is used; the uv interpreters require
macOS 13, so crossing 13 also lets uv become an unconditional default
(see step 9).
1. Makefile
Makefile has a single source of truth for the native builds:
DEPLOYMENT_TARGET=13.0Every native recipe (openssl, libssh2, libgit2, libsixel) references$(DEPLOYMENT_TARGET), so this one line covers them. Confirm nothing else
hardcodes a version:
grep -nE "macos1[0-9]|version-min=1[0-9]|OSX_DEPLOYMENT_TARGET" Makefile | grep -v '\$(DEPLOYMENT_TARGET)'2. Main project: iTerm2.xcodeproj
Bump every MACOSX_DEPLOYMENT_TARGET that is below the new floor, and
leave intentional outliers alone:
- Bump the app-family values (the bulk, plus
it2core). - Leave targets that are already higher (e.g. the browser / WebExtensions
targets at 15.0) - do not lower them. - Leave deliberately-ancient test-only targets (e.g.
iTerm2ForApplescriptTestingat 10.12); a low floor there is harmless
because the shipped app requires the new minimum.
Find what belongs to what:
python3 - <<'EOF'
import re
f=open('iTerm2.xcodeproj/project.pbxproj').read()
for m in re.finditer(r'([0-9A-F]{24}) /\* (\w+) \*/ = \{\n\t\t\tisa = XCBuildConfiguration;(.*?)\};',f,re.S):
dt=re.search(r'MACOSX_DEPLOYMENT_TARGET = ([0-9.]+);',m.group(3))
if dt: print(dt.group(1), m.group(2))
EOF3. App-embedded sub-projects (not submodules)
These build into frameworks the app links, and their pbxprojs are tracked
in the main repo. Bump each one's app-level MACOSX_DEPLOYMENT_TARGET
(leave their old demo/test alt-configs):
- BetterFontPicker/BetterFontPicker.xcodeproj
- ColorPicker/ColorPicker.xcodeproj
- SearchableComboListView/SearchableComboListView.xcodeproj
- SignedArchive/SignedArchive.xcodeproj
- ThirdParty/GZIP/GZIP.xcodeproj
- iTermBrowserPlugin/iTermBrowserPlugin.xcodeproj (bump its
12, leave the
15.x WebExtensions targets)
Framework Info.plist LSMinimumSystemVersion values and the demo/test
plists use $(MACOSX_DEPLOYMENT_TARGET) or are Xcode-generated, so they
inherit automatically - no hand-editing.
4. Submodules we vend (github.com/gnachman/*)
Only bump submodules whose remote is github.com/gnachman/*. Others
(BTree, fmdb, libgit2, libsixel, libssh2, powerline-extra-symbols,
noise-c) are left alone: a dependency built for a lower floor links and
runs fine inside a higher-floor app, and we do not want to drift them from
upstream.
List the remotes to be sure:
git config -f .gitmodules --get-regexp urlThe vendored ones with a macOS Xcode target: CoreParse, Highlightr,
MultiCursor, NMSSH, Sparkle, SwiftyMarkdown. For each:
- Bump every
MACOSX_DEPLOYMENT_TARGETin its.xcodeprojthat is
below the floor (leave anything already higher). Do not bump just the
"main" config: a partial bump leaves the framework building at the old
min (this is exactly why NMSSH kept building at 10.15 through the 11->12
bump). NMSSH also carries prebuiltNMSSH-OSX/Libraries/lib/*.a
(openssl/libssh2) that get rebuilt at the new floor by the deps build. - Commit inside the submodule:
git -C submodules/X commit -m "Bump deployment target to macOS 13". These land on a detached HEAD. - Bump the superproject pointer:
git add submodules/X. - Push the fork so other machines/CI can fetch the new SHA:
git -C submodules/X push origin HEAD:.
Not bumped, and why:
- openssl (gnachman fork) builds via the Makefile
DEPLOYMENT_TARGET,
no pbxproj target to touch. - adblock-rust, railroad_dsl (Rust) and iTerm2-shell-integration (shell)
have no Apple deployment target and emit no Apple deprecation warnings. - SFSymbolEnum is a codegen package (its output compiles under the app's
target), so its own platform is irrelevant.
5. Local Swift packages
Bump .macOS(.vN) in each manifest:
- WebExtensionsFramework/Package.swift (covers BrowserExtensionShared too)
- Companion/CompanionCore/Package.swift (CompanionNoise/Protocol/Transport)
- it2cli/Package.swift, cc-status/Package.swift, pwmplugin/Package.swift
Caveats:
- The
.vNenum must exist in that package'sswift-tools-version
(.v13needs 5.7+). Older-tooled packages (e.g. tools 5.1/5.3) cannot
name it - either raise the tools version or leave the package. - Packages the app consumes via a prebuilt framework (built from an
.xcodeproj, e.g. Highlightr/SwiftyMarkdown) do not readPackage.swift
during the app build, so those manifests do not affect app warnings.
Bump them for standalone/SPM consumers only.
6. Rebuild the vendored dependencies
make -j16 paranoid-depsparanoid-deps runs tools/check-submodule-cleanliness first, which
aborts non-interactively if any submodule is dirty or points at a
different commit than the superproject index. So before running it:
commit the submodule edits (step 4) and stage their pointers, and clear
transient build dirs the previous run left (e.g. submodules/*/build*,submodules/openssl/build-fat, submodules/CoreParse/Build). A rebuild
re-dirties NMSSH's .a files; fold those into its commit (amend) and
re-stage the pointer.
Verify the rebuilt binaries carry the new floor:
otool -l ThirdParty/NMSSH.framework/Versions/A/NMSSH | awk '/LC_BUILD_VERSION/{f=1} f&&/minos/{print $2; exit}'
plutil -extract LSMinimumSystemVersion raw ThirdParty/CoreParse.framework/Versions/A/Resources/Info.plist7. Rebuild the standalone universal binaries
The committed CLI/adapter binaries (it2cli/bin/it2, cc-status/bin/cc-status,pwmplugin/binaries/*) are universal and must be rebuilt to pick up the
new floor:
make BUILD_DIR="$PWD/Build" UNIVERSAL=1 paranoid-it2cli paranoid-cc-status paranoid-pwmadaptersBUILD_DIR=... is required: the sandboxed sub-make cannot runxcodebuild -showBuildSettings to discover it, and it2cli/cc-status
do not pass it through (only pwmadapters does). Verify each slice:
for b in it2cli/bin/it2 cc-status/bin/cc-status pwmplugin/binaries/*; do
echo "$b $(lipo -archs "$b"): $(otool -l "$b" | awk '/minos/{printf "%s ",$2}')"
done8. Fix the deprecations the new floor surfaces
The project builds warnings-as-errors, so make Development stops at the
first newly-deprecated API. To survey them all at once, build with the
promotion disabled:
make clean
xcodebuild -scheme iTerm2 -configuration Development -destination 'platform=macOS' \
-skipPackagePluginValidation CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO \
CODE_SIGNING_ALLOWED=NO ARCHS="arm64" ONLY_ACTIVE_ARCH=YES SYMROOT="$PWD/Build" \
SWIFT_TREAT_WARNINGS_AS_ERRORS=NO GCC_TREAT_WARNINGS_AS_ERRORS=NO > tmp/w.log 2>&1
grep -E "deprecated in macOS 13|first deprecated in macOS 13" tmp/w.log | sort -uThen fix:
- Swift deprecations must be fixed (they cannot be suppressed inline).
- ObjC deprecations can be fixed, or wrapped in
#pragma clang diagnostic ignored "-Wdeprecated-declarations"when the
migration is behaviorally risky (e.g. SCEvents' run-loop -> dispatch-queue
FSEvents change, for which no upstream/fork migration exists).
make clean gotcha: the clean target runs rm -rf ThirdParty && git checkout ThirdParty, which reverts uncommitted edits under ThirdParty/
(e.g. a SCEvents pragma). Commit ThirdParty fixes before cleaning.
Finish with a clean make Development (warnings-as-errors on) to prove it
is green.
9. uv Python runtime default
The uv interpreters need macOS 13. Once the floor is 13+, thepythonRuntimeUsesUV advanced-setting default insources/Settings/iTermAdvancedSettingsModel.m can be plain YES (drop
any OS-conditional helper).
Reference
Prior bumps: the macOS 11->12 change was 8963d0574 (app + rebuilt deps)
plus fa7b231 (submodule deployment targets, via per-submodule "Bump
deployment target" commits and pointer moves).
Companion Push Relay (`docs/companion-push-relay.md`)
iTerm2 Companion Push Relay
The push relay is a Cloudflare Worker, deployed separately from the
room relay, that lets a Mac send a push notification to its paired
phone when the phone is backgrounded or away. It is a separate
deployment with its own stored secret, so a compromise of one is not a
compromise of the other. See companion-relay-design.md for the room
relay it sits alongside.
What it stores
- The phone's APNs device token.
- A hash of the push secret.
- The APNs signing key (the
.p8).
Credential pattern
The push secret follows the same pattern as the room relay'sroomSecret: the phone mints it, registers a derived value (its hash)
with the push relay over TLS, and couriers the secret itself to the Mac
inside the Noise channel. The relay only ever stores the hash, never
the secret.
Sending a push
MAC PUSH RELAY (Worker) APPLE / PHONE
| |
| (earlier) phone registered: APNs token, hash(secret) |
| (earlier) phone couriered the push secret to the Mac |
| |
| POST { token, secret, title, body } --[TLS]--> |
| verify hash(secret), rate-limit |
| sign APNs JWT (.p8), forward ---> APNs ---> phoneThe Mac presents the push secret it was couriered; the relay checks it
against the stored hash, so the relay will only push to a phone on
behalf of its paired Mac.
Companion Relay Design (`docs/companion-relay-design.md`)
iTerm2 Companion Relay: Design
Status: implemented.
Scope: remote communication between a paired Mac and iPhone over the
internet, including pairing on networks that forbid peer-to-peer
traffic.
Goal
A paired phone and Mac should be able to communicate whenever both
have internet access, with no requirement that they be on the same
network. Pairing must work on client-isolated networks (corporate
WiFi, hotels) and across networks (phone on cellular, Mac on
ethernet). If a network blocks outbound HTTPS to the relay, the
devices cannot reach each other; that case is out of scope.
Non-goals
- Peer-to-peer NAT traversal (ICE/STUN hole punching). It isn't
worth the complexity here: the traffic is small (chat messages and
occasional ~100KB session tiles), and the main remote case (phone on
cellular) sits behind carrier-grade NAT where hole punching usually
fails anyway. If we ever want it, the relay's Durable Object can
serve as the ICE signaling channel; nothing here rules that out. - Background connectivity on the phone. iOS kills our sockets when
the app is backgrounded; push notifications cover that case. - Trusting the relay. The relay only ever sees ciphertext, and the
design must stay safe even if it doesn't. In particular, every
protection below must hold even when the Worker runs fully open
(ATTEST_REQUIRED=false; see Bring Your Own Relay). The quota and
admission machinery is therefore mandatory, not optional tuning.
Background: what already exists
- Security. Every connection runs
Noise_XK_25519_ChaChaPoly_BLAKE2s. The QR pins the Mac's static
public key (rs), and the handshake prologue binds the pairing id
(pid). The phone's static key crosses the wire only encrypted; the
Mac's never crosses at all.
Architecture
The relay is a public server that lets a phone and Mac find each
other and exchange encrypted frames when they are not on the same
network. Its main job is to splice their two connections, copying
frames between them, and it cannot read what it carries: all traffic
is end-to-end encrypted. But it is not a pure pipe. Before splicing a
connection it admits the client (checking that it is legitimate), and
throughout it enforces quotas and other limits to protect itself from
abuse. Those checks are detailed in later sections.
The parties:
- The Mac and the iPhone: a pair of devices that want to
communicate, whether or not they are already paired. All traffic
between them is end-to-end encrypted with Noise, so nothing on the
path, including the relay, can read or alter it. - The relay operator: whoever runs the relay server. By default
this is the iTerm2 project's hosted instance, but anyone can run
their own (see Bring Your Own Relay). The operator sees connection
metadata (IPs, timing, volume) and can deny service, but cannot read
traffic or impersonate either device.
The relay is a Cloudflare Worker backed by one Durable Object (DO) per
pairing. Both devices connect outbound to it over WebSocket (wss), so
there are no inbound firewall holes to open on either end. They meet
in a room (one DO), which performs the admission checks and then
splices the two connections.
The relay is its own Worker deployment, kept separate from the
project's other services (see companion-push-relay.md) so that the
high-traffic, most-exposed splice path cannot reach their secrets if it
is compromised.
The QR code
The Mac displays a QR that the phone scans to begin pairing. It encodes
an iterm2://pair URL with these fields:
v— format version.proto— the Noise protocol name
(Noise_XK_25519_ChaChaPoly_BLAKE2s).rs— the Mac's static public key (base64url).pid— the pairing id (the random nonce mentioned above).relay— the relay's base URL (an HTTPS origin). Optional; if
absent, the phone uses the official default relay. This is how a
self-hoster points a pairing at their own relay.
Everything the phone needs to locate the room (rs, pid, relay)
and to authenticate the Mac end-to-end (rs, proto) comes from the
QR and nowhere else.
Room names
Both devices must derive the same room name with no coordination, and
no one else should be able to guess it. The name is a hash of the
pairing's secret material, both halves delivered by the QR: rs (the
Mac's static public key) and pid (a random nonce the Mac chooses for
the pairing).
roomName = SHA256(canonical("iterm2-room", [rs, pid]))canonical is a domain-separated, length-prefixed encoding: each
element is prefixed with its 4-byte length, behind the label"iterm2-room". This makes the rs/pid boundary unambiguous and
keeps the hash from ever colliding with another use of the same
inputs. Because rs only ever travels inside the QR, the room name is
unknowable to anyone who has not scanned it, which rules out room
squatting and probing.
The room name is sent to the Worker in a request header, never in the
URL path. A path like /room/<name> would record the pseudonym in
Cloudflare's edge request logs, which no operator setting can disable;
a header keeps it out. (The Worker needs the name before it can
address the DO, so it cannot wait for the first WebSocket message to
carry it.)
On the app side, the relay is reached from two directions:
- Phone: connects to the relay, authenticates, and once the room
splices it through, gets back a transport for the Noise channel to
run over. - Mac: parks in the room, meaning it holds an open connection
there waiting for the phone to appear. The Mac parks during pairing
and whenever it is paired but currently disconnected.
The Noise handshake runs end-to-end through the splice on every
connection, so the relay only ever sees ciphertext. (A Noise handshake
also exposes each side's ephemeral public key in the clear, but those
are useless to the relay.)
Framing: one WebSocket binary message carries exactly one frame, since
the WebSocket layer already provides message boundaries. The Noise
channel's existing chunking of payloads larger than 64KB is unchanged.
The relay caps frame size itself, well below Cloudflare's 1 MiB
WebSocket message limit, before splicing.
App Attest keys
To prove to the relay that a phone is running the real iTerm2 Buddy app
rather than a script abusing it, the phone uses Apple's App Attest.
Each pairing gets its own App Attest key. Over time a phone may pair
more than once (each re-pairing creates a new room on the relay), and
if one App Attest key were reused across those pairings, the relay
would see the same key id in each of that phone's rooms and could link
them as belonging to a single device. A per-pairing key prevents that.
The cost is one extra attestation per pairing and a small, predictable
amount of key churn, low enough to stay within the per-IP limits on
new keys (see Abuse).
Credentials and gatekeeping
Every connection tells the relay its role, phone or Mac. The relay
needs this because the two roles are admitted differently and occupy
separate slots in the room (described later).
Admission is purely abuse prevention: it controls who may spend the
relay's bandwidth, never who may read the traffic. Confidentiality and
authenticity between the two devices come entirely from the end-to-end
Noise channel, which the relay cannot see into. A wide-open relay with
attestation disabled is therefore still safe for its users; it is only
cheaper to abuse.
The relay admits a connection differently depending on whether the
pairing already exists:
- Before pairing, there is no shared secret yet, so the phone
proves it is running the real iTerm2 Buddy app using App Attest. The Mac
cannot attest (App Attest is not available off the App Store) and
simply parks. - After pairing, both devices share a secret established during
pairing, and each proves it holds that secret to rejoin the room.
Pairing rooms (no shared secret yet)
App Attest lets the phone authorize itself without anything from the
Mac. Again, this is only an abuse gate, allowing a signed iTerm2 Buddy
install to spend relay resources while turning away a script; it says
nothing about who the phone is talking to.
- The phone scans the QR and requests an attestation challenge (a
fresh 32-byte random nonce) from the relay. It refuses non-HTTPS
relay URLs and refuses redirects, and it reduces therelay=URL to
scheme, host, and port at parse time, rejecting any userinfo, path,
query, or fragment. (The phone builds its own endpoint paths from
that origin, so an embedded path or userinfo in the QR could only be
an attack.) - The phone generates its per-pairing key and attests, binding the
attestation toclientDataHash = SHA256(canonical("iterm2-relay-attest", [challenge, relayOrigin])),
whererelayOriginis the origin the phone believes it is talking
to. (The canonical, length-prefixed encoding makes the
challenge/origin boundary unambiguous.) The relay verifies the
attestation chain to Apple's root, checks the app ID, and confirms
thatrelayOriginis its own. That last check closes a cross-relay
forwarding attack: a hostile QR pointing atrelay=evil.example
could otherwise pass a valid challenge from the official relay
through the phone and spend the official instance's resources under
a victim device's attestation. - If verification succeeds, the relay issues the phone a pairing-room
ticket: a single-use, short-lived (minutes) token bound to the
attest key id and the room name. The phone presents this ticket when
it opens its WebSocket to the room. The ticket carries the result of
the HTTPS attestation over to the WebSocket connection, so the relay
can admit the phone to the splice without rerunning attestation
there, and it ties that admission to this one room. - The Mac parks in the room it named. The DO begins splicing only once
a ticketed phone arrives.
The tickets, challenges, and their single-use and expiry state all
live in the room's DO, which is already addressed by the room name.
They are deliberately not kept in Workers KV (Cloudflare's key-value
store), because KV is eventually consistent and cannot enforce
single-use. Outstanding challenges are capped in number and expire on
their own, so issuing challenges cannot be used to fill the DO's
storage.
Pairing confirmation (the anti-hijack step)
Because the relay works from anywhere, the QR no longer has to be near
the Mac to be used. A photograph of it is enough for an attacker to
attempt pairing from anywhere, using a real phone and a valid
attestation (the attacker's phone is running the real iTerm2 Buddy too,
so attestation does not distinguish it), by racing the real phone into
the handshake. The mitigations below, all required, defend against it.
Short pairing validity. The QR (and its pid) expires after two
minutes of inactivity; the pairing window then regenerates it.
Mac-side verification with a short authentication string (SAS).
Completing the Noise handshake does not finalize pairing. Both ends
derive the same SAS from the Noise handshake hash, which commits to
both static keys, using a fixed label and a fixed length (6 decimal
digits). The user confirms it, and the UI is deliberately asymmetric:
the phone displays the SAS, and the Mac only accepts input. The user
reads the digits off the phone and types them into the Mac, which
compares them against its own derived SAS. The Mac must never display
the code itself: if it did, the user could read the Mac's number and
type it straight back, which always matches and defeats the check.
Typed entry (rather than a single Accept button, or a
pick-the-matching-code list) forces the user to engage with the actual
digits, which resists blind clicking. (A picker was considered and
rejected: its best case is still a 1-in-N chance for an attacker to
win by blind guessing.)
The SAS commits to one handshake. While the confirmation UI is up,
a new connection can take over the phone's slot in the room (the room
keeps only the newest connection in each role; see admission below):
an attacker displacing the real phone, or the real phone displacing an
attacker. The relay closes the displaced socket. On the Mac, the
replacement surfaces as a fatal error on the Noise channel, because the
new connection's bytes cannot decrypt against the handshake already in
progress, and an AEAD failure tears the channel down rather than being
skipped (see Reconnection). That voids the pending confirmation, which
was bound to one specific handshake hash; the Mac re-parks, and the
next phone produces a fresh handshake and a freshly derived SAS. The
phone that loses the race does not sit silently: it keeps showing its
own SAS along with guidance ("your Mac is showing a code; if it does
not match this, reject"), so a hijacker's SAS on the Mac will visibly
fail to match the code on the real phone in the user's hand.
The phone blanks its SAS the instant its connection drops. A
6-digit SAS is only about 20 bits. Without this rule, an attacker who
photographed the QR could reconnect over and over during the pairing
window, getting a fresh handshake hash (and so a fresh SAS) each time,
and keep trying until one happened to match the victim phone's
still-displayed, by then stale, code, which the user might then type
into the Mac. Clearing the phone's SAS the moment its connection dies
removes that target.
The DO caps phone-slot turnover per room at eight cycles
(MAX_PAIRING_CYCLES). The DO cannot see the end-to-end Noise
handshake, but it counts how many times the phone slot is taken while
the Mac is parked, and each new phone connection is one more handshake
attempt. The count resets on every fresh Mac park, and when it exceeds
eight the room is killed and the QR must regenerate. Counting per park
rather than per QR matters because a confirmed-but-not-yet-established
Mac re-parks in pairing mode without a QR (see the lifecycle section),
and each fresh park gets its own budget. Together with
blank-on-disconnect, this makes grinding through the roughly 2^20
possible SAS values hopeless even when attestation is off.
Wrong SAS entries regenerate the QR. The Mac accepts three typed
attempts; a code still wrong after that (the other visible sign of a
hijack, or just a typo) is treated as a failed pairing: the Mac voids
it and regenerates the pid, invalidating the photographed QR.
Mac-to-phone accept/reject. The SAS is checked only on the Mac, so
the phone cannot tell on its own whether it succeeded; without a signal
it would sit on the pairing screen forever, even after a success. So
once the typed code matches, the Mac sends an explicit "pairing
accepted" control message to the phone over the now-active Noise
channel. This is the first application message on the channel, and it
is the phone's cue to stop showing the SAS, move into the chat list,
and only then begin the work that turns the pairing into an
established room (the next section). The phone does none of that
beforehand. On a wrong code, an abort, or a timeout, the Mac instead
sends "pairing rejected" so the phone fails fast rather than waiting
out its own timeout. The phone keeps an independent pairing-window
timeout regardless (no accept means failure) and blanks its SAS on
disconnect or timeout. It can trust the accept because the channel is
end-to-end authenticated by the completed handshake: the message
provably came from the device the phone handshook with, and only the
phone whose code the user typed correctly on the Mac ever receives one.
The human's trust decision happened on the Mac.
Either side can abort at any time. Both the Mac's confirmation
screen and the phone's SAS screen show a Cancel control throughout the
pairing window. Aborting on the Mac sends "pairing rejected" and tears
down; aborting on the phone closes its connection, which the Mac sees
as its slot dying, voiding any pending confirmation. Either way nothing
is committed: the pairing is not finalized and the room is not
established, and the QR regenerates for a fresh attempt. No half-paired
state is left on either side.
The phone's confirmation screen also shows the relay host wheneverrelay= differs from the official default, so a camera-scanned hostile
QR pointing at an attacker's relay is disclosed to the user. The host
is shown as ASCII/punycode, never its Unicode form, so a lookalike of
the official hostname cannot be made to read as legitimate.
Established rooms
Once pairing is confirmed, the room becomes established, and from then
on either device can rejoin it without pairing again. Rejoining is
authenticated, and the credential is built so that the relay can check
a join but never forge one.
It all starts from the roomSecret: a 32-byte random value the phone
mints. From it, both devices independently derive the same Ed25519
signing keypair. (The private key seed isHKDF-SHA256(roomSecret, info = "relay-auth-ed25519"); the label keeps
this key distinct from any other use of roomSecret.) The public half
of that keypair is the verifier, V.
The two halves are used in opposite places:
- The relay stores only
V. To rejoin, a device signs a challenge from
the relay with its private signing key, and the relay checks the
signature againstV. (The full join exchange is described below.) - Because the relay holds only the public
V, it can verify a join but
never produce one. A one-time relay compromise or storage dump leaks
only public keys and authorizes nothing, then or later.
(A symmetric key stored at the relay would be far worse: a single dump
would let an attacker occupy and displace devices in every room until
the key rotated, which in practice happens only on unpair. Storing onlyV also closes a TLS-MITM concern, since even an enterprise root that
captured the registration traffic would see nothing but V.)
Establishing the room runs during the first confirmed session, in this
order:
The phone mints the 32-byte
roomSecret(kept in the Keychain,
rotated on unpair) and derives the signing keypair.The phone sends
roomSecretto the Mac inside the Noise channel, as
a field of the connection-time status message (resent on every
connect, so it self-heals and re-keys automatically), and waits for
the Mac's in-channel ack. The Mac now derives the same keypair, so
both sides can sign joins.Only after that ack does the phone register
Vwith the relay, over
HTTPS (a separate connection from the splice), which flips the room
to established.The order matters. Establishing a room permanently closes
pairing-mode admission, and the Mac can only join an established
room once it holdsroomSecret. If the phone registeredVfirst
and the session died beforeroomSecretreached the Mac, the room
would be established but the Mac would have no signing key: it could
not park, and the room would be stranded until the user re-paired.
Acking before registering avoids this. The reverse failure is
harmless: if the Mac acks but registration then fails, the room
simply stays in pairing mode and the phone retries on its next
connect.This first registration is tied to the pairing session. At phone
admission the DO mints a one-time registration token; the phone
presents that token alongsideV, and the DO accepts a room's firstVonly with a valid, unused token. That bounds the open-mode race
over who gets to registerVto the pairing room's lifetime, even
though registration does not ride the spliced connection. With
attestation enabled, the phone also proves it is the same device
that attested during pairing: it signs the relay's challenge with
its per-pairing App Attest key (an App Attest assertion), and the
relay checks that signature against the public key it recorded at
attestation, with a counter that must strictly increase to block
replay.The relay accepts only the first registration of
Vfor a room and
refuses any later one ("already registered"). This keeps
registration from becoming an overwrite channel: otherwise anyone
who learned the room name could register their ownVand take over
the room. As a result,Vcannot be replaced today, and the way to
get a new one is to re-pair.
The pairing-to-established switch happens atomically inside the DO, and
once it does, pairing-mode admission is closed for good: no second
ticketed phone can ever join.
From then on, every connection to the room, by either device, is
authenticated the same way: a signature challenge-response. The DO
sends a fresh nonce; the device replies with a signature over a
fixed-length transcript of (protocol version, role, nonce, room name,
relay origin), which the DO verifies against the stored V. Binding
the room name and origin into the signed bytes makes the signing key
useless on any other room or relay, even if some future change reused
it. Since the relay holds nothing that can authorize a join, even a
fully compromised or dumped relay cannot mint one, and roomSecret
never leaves the Noise channel between the two devices.
All signature and ticket comparisons on the relay are constant-time.
In short, the Mac's standing rests entirely on holding a secret that
only the confirmed phone could have minted and only the paired Mac
could have received.
The confirmed-but-not-yet-established window
Between SAS confirmation and the phone registering V, the room is
confirmed but not yet established. Normally this window is tiny: the
phone registers V as soon as the Mac acks roomSecret, within the
same session. But if the session drops in between, the Mac is paired
yet has never seen the room established, and two things follow.
First, the Mac parks in pairing mode, because it has no established
room to park in yet. Pairing-mode rooms expire after a couple of
minutes, so the Mac does not hold one open indefinitely; it re-parks
opportunistically rather than keeping a permanent presence. Once the
phone registers V on a later connect, the room becomes established
and the Mac parks persistently as normal.
Second, this is the one moment a paired Mac sits in a room whose
admission still says "new pairings welcome," so the DO could splice
some other admitted connection to it (any ticketed phone under
attestation, or any peer in open mode). The Mac defends by pinning the
phone static key it recorded at confirmation: it rejects any Noise
handshake from a different static, and it never re-enters the
pairing/SAS UI, completing the handshake only with the already-confirmed
phone. (Noise XK provides the rejection; the requirement is just that
this Mac not treat a new static as a new pairing.)
Deletion, revocation, and expiry
Unpairing, on either side, sends the relay an explicit delete-room
request and deletes the device's local roomSecret. The request is
authenticated: before discarding roomSecret, the device signs a fresh
DO-issued nonce with the room's signing key (the private half of V,
derived from roomSecret), exactly like a join. That keeps a captured
delete signature from being replayed, and because it is an ordinary
signature it works in open mode too. Without this authentication,
anyone who knew a room name could destroy rooms.
Delete is best-effort, though: the relay might be unreachable at
unpair, or the device wiped, or the app deleted. So established rooms
also carry a 30-day idle TTL. This is housekeeping for the operator,
not a security control: the retained record authorizes nothing, so the
TTL exists only to keep dead rooms from accumulating in storage. Every
authenticated connection (a device joining, or the Mac re-parking)
refreshes the room's last-activity stamp, so the TTL never reaps a room
still in use; only a room left untouched for 30 days is deleted. The
retained record is pseudonymous: the room name is a derived hash, V
is a public verifier, and the attest key id (when present) is opaque.
A device that comes back after its room was reaped simply re-pairs. Its
reconnect finds no verifier (its signed join is refused, or in open
mode the room is just empty), and the app surfaces a "remote access
needs re-pairing" prompt rather than failing silently. Re-pairing mints
a fresh pid and room, so a leaked-QR holder who squats the reaped room
in the meantime gains nothing: the real devices never join it (their
signing key does not match the squatter's verifier) and move to a new
room anyway.
Pairing flow
This is the end-to-end sequence with the real requests and
cryptographic values. Participants: the phone, the relay (its
Worker and the room's DO), Apple's App Attest service, and the
Mac. Every HTTP and WebSocket request to the relay carries the room
name in an x-relay-room header (64 lowercase hex chars) and a fixed
User-Agent, and the relay rejects any request bearing an Origin
header (only browsers send one).
0. The Mac sets up the room and parks. It loads or generates its
Noise static keypair, mints a random pid, computesroomName = SHA256(canonical("iterm2-room", [rs, pid])), and opens awss connection to the relay to park: it runs the admission
handshake declaring itself the Mac, then holds the socket open waiting
for the phone.
Mac -> DO: Hello { v, role: "mac" }
DO -> Mac: Challenge { nonce }
Mac -> DO: Proof { } # a pairing-mode Mac is pre-auth:
# no ticket, no signature
DO -> Mac: Result { ok: true }It then blocks reading the socket, waiting for the phone's first frame,
and displays the QR from "The QR code" (expires after two minutes of
inactivity, then regenerates).
(The DO sends a Challenge nonce on every admission, but only
established-room joins use it, where the proof is a signature over a
transcript binding the nonce. In pairing mode neither the Mac's empty
proof nor the phone's ticket consumes it; the DO sends it regardless so
an observer cannot tell from the reply whether a room is in pairing or
established mode.)
1. The phone ingests the QR. Either camera works, the system Camera
app or the in-app scanner, and a scan pairs directly. A pairing link
(iterm2://pair?...) opened from somewhere else, such as a webpage,
instead lands on a confirmation screen that shows the relay host first
(in punycode when it is not the official companion-relay.iterm2.com),
so a page cannot silently aim the phone at an attacker's relay. The
phone canonicalizes relay= to a bare https origin and stores rs,pid, and that origin.
2. The phone attests (HTTPS, separate from the splice):
phone -> relay: POST /attest/challenge (x-relay-room: <roomName>)
relay -> phone: { challenge } # fresh 32-byte nonce, base64
phone: keyId = AppAttest.generateKey() # contacts Apple
cdh = SHA256(canonical("iterm2-relay-attest",
[challenge, relayOrigin]))
att = AppAttest.attestKey(keyId, cdh) # Apple signs it
phone -> relay: POST /attest { challenge, attestationObject: att }
relay: verify att chain to Apple root, app id, AAGUID environment,
and that cdh embeds the relay's own origin
relay -> phone: { ticket } # 24 random bytesOn success the DO stores the ticket (ticket:<ticket> -> keyId, 5-min
TTL) and returns a copy to the phone. The ticket is in effect a
single-use shared secret between the phone and this DO: the phone
presents it in step 3, and the DO checks it against the stored copy and
deletes it. The phone also pins keyId to this room so the step-6
assertion reuses the same attested key. (In open mode the relay answers/attest with 400 attestation disabled and the phone continues with
no ticket.)
3. The phone joins the room (WebSocket upgrade, room header),
running the admission handshake with the DO:
phone -> DO: Hello { v, role: "phone" }
DO -> phone: Challenge { nonce }
phone -> DO: Proof { ticket } # ticket from step 2
DO: look up ticket:<ticket>, check unexpired, delete it (single-use)
DO -> phone: Result { ok: true, registrationToken } # one-time, used in step 6With both slots now filled, the DO starts splicing and from here copies
frames between the two sockets blindly.
4. Noise XK handshake (end-to-end through the splice). The phone is
the initiator, since it knows the Mac's static rs from the QR; the
prologue on both ends is "iterm2-companion/v1/pid:" + pid:
phone -> Mac: msg1: e
Mac -> phone: msg2: e, ee
phone -> Mac: msg3: s, se # phone's static, sent encryptedThe Mac's static is never transmitted (the phone already had it); the
phone's crosses only inside msg3's encryption. A wrong rs or pid
makes the two sides' handshake hashes diverge and the handshake fails
its MAC. After msg3 both hold the same transport keys and the same
handshake hash h.
5. SAS confirmation. Both sides deriveSAS = HKDF(h, "iterm2-sas-v1") reduced to 6 decimal digits. The phone
shows it; the user types it into the Mac, which compares it against its
own. On a match the Mac sends the first application message over the
now-encrypted channel:
Mac -> phone: {"pairing":"accepted"} # or {"pairing":"rejected"}Nothing in step 6 happens before this arrives.
6. The phone establishes the room. Over the Noise channel the phone
sends roomSecret (32 random bytes) in its connection-time status
message and waits for the Mac's in-channel ack, so both derive the join
keypair: a private signing key and its public verifier V. Only then
does the phone register V. Under attestation it must also prove it is
the same device that attested in step 2, so it fetches a fresh
challenge and has its Secure Enclave produce an App Attest assertion
(which signs the challenge and carries a monotonic counter):
phone -> relay: POST /attest/challenge ; { challenge } # fresh, for the assertion
phone: assertion = AppAttest.generateAssertion(keyId,
SHA256(canonical("iterm2-relay-attest", [challenge, relayOrigin])))
phone -> relay: POST /register {
registrationToken, # from step 3
verifier: V,
challenge, assertion # both omitted in open mode
}
relay: token unused?; verify the assertion against the key attested in
step 2; counter strictly greater than the last one seen
relay -> phone: ok # room now establishedThe room flips to established atomically, and pairing-mode admission
closes for good.
Nothing here requires the two devices to share a network. Physical
proximity matters only for showing the QR to the phone's camera and
reading the SAS off the phone; everything else runs through the relay
from anywhere.
Reconnection
After pairing, either device reconnects on its own. The Mac parks in
the established room whenever it is paired but disconnected, retrying by
itself if the connection drops; the phone reconnects on app open or
after losing its connection. Both authenticate the same way, as
established-room joins: each signs the DO's challenge with its private
signing key (signingKey, the private half of V, derived fromroomSecret during pairing), and the DO verifies the signature against
the stored V. There is no SAS after the first pairing, since both
static keys were pinned then.
A reconnect (here, the Mac parking) looks like:
Mac -> DO: Hello { v, role: "mac" }
DO -> Mac: Challenge { nonce }
Mac: sig = Sign(signingKey,
canonical("iterm2-relay-join",
[v, "mac", nonce, roomName, relayOrigin]))
Mac -> DO: Proof { sig }
DO: verify sig against the stored V; refresh last-activity
DO -> Mac: Result { ok: true }The phone's join is identical with role: "phone". Binding the role,
room name, and origin into the signed transcript keeps a captured
signature from being replayed into a different role, room, or relay,
and binding the fresh nonce keeps it from being replayed at all. Once
both slots are filled the DO splices, and the two run a fresh Noise XK
handshake (step 4 of Pairing flow), after which the channel is live. No
SAS, no accept/reject.
Hibernation keeps a parked Mac nearly free. Cloudflare evicts the
DO from memory while the WebSocket stays open, and billing accrues only
while frames actually flow. Parked connections still need keepalive
pings to survive NAT timeouts; these are answered by the hibernation
API's auto-response, so they never wake the DO or bill duration. This is
what makes an always-parked Mac affordable.
AEAD failure is fatal. The relay can drop, reorder, or replay
frames; with Noise's counter-based nonces, any of those shows up as an
AEAD decryption failure after the handshake. A single such failure
tears the transport down completely and forces a fresh handshake,
rather than skipping the offending frame. That turns any relay
frame-tampering into a clean reconnect instead of undefined behavior.
(The rule is stated explicitly because the lenient default would be to
skip and continue.)
DO admission and connection hygiene
These controls run in every configuration, including a fully open relay
with attestation off, so they are load-bearing, not tuning. Some are
cost-motivated; the strategic view of cost and abuse is in Abuse and
cost control below.
Admission is the first traffic, and it is bounded. The
Hello/Challenge/Proof/Result exchange must complete before anything is
spliced; pre-auth bytes are never buffered or forwarded. Each pre-auth
control frame is capped at 8KB, the exchange has a 15-second deadline,
and a violation closes the socket silently. A room holds at most 4
simultaneous pre-auth sockets; when a fifth arrives the oldest is
dropped (close 1008), bounding per-room memory and wake cost during the
deadline window. The Hello carries a protocol version byte, so a future
admission change can never be mistaken for v1.
The first reply is uniform, to avoid a mode oracle. A naive DO
would answer differently for a pairing-mode, established, or absent room
(different challenge types), letting anyone who knows a room name probe
its state. Instead the DO always replies to Hello with a genericChallenge { nonce } and branches only on what the client then
presents (a ticket or a signature). This matters little, since only
someone who already holds the unguessable name can reach it, but it
costs nothing to close.
WS upgrades carrying an Origin header are rejected. Browsers
attach Origin to every WebSocket upgrade and there is no CORS gate on
WS, so any webpage could otherwise conscript its visitors' browsers
into burning the relay's daily request cap against guessed room names,
from residential IPs that per-IP limits never catch. Native clients
send no Origin, so one header check removes the entire in-browser
attack surface for free.
Two slots per room, one per role; newest wins, but displacement is
auth-gated. Each room has exactly one phone slot and one Mac slot.
When a second connection arrives for a slot that is already taken, the
newer one wins, but only if it presents the credential that slot
requires, checked by the DO before any splice: a valid join signature
for an established room, or a valid pairing ticket for a pairing room.
(It has to be a DO-checkable, pre-splice credential. The DO is blind to
the end-to-end Noise handshake, so it cannot gate displacement on that:
a challenger cannot handshake until it is spliced, and cannot be
spliced until it displaces.)
- Established room, phone slot: the join signature is checked by the
DO before splicing, so displacement gates on it directly. A new
connection takes the slot only after verifying againstV. - Pairing room, phone slot: the DO gates displacement on the pairing
ticket (also DO-checkable, checked before splicing). Under
attestation this preserves the intent: only another attested,
ticketed phone can disturb a mid-pairing victim, never a random
pre-auth socket. In open mode there is no ticket, so it degrades to
displace-on-connect, which the per-park cycle cap and SAS voiding
already bound. - Mac slot (always pre-auth, since the Mac cannot attest): the newest
Mac-role parker wins. First-wins would be worse: the real Mac's
silently-dead socket (NAT timeouts can lag WS close detection by
minutes) would block the real Mac from reclaiming its slot for the
rest of the window. Newest-wins is self-healing, since the real Mac
just re-parks. A QR-photo squatter flapping the Mac slot is only a
nuisance: it lacks the Mac's static private key, so the phone's XK
handshake against it fails and the attempt burns a cycle against the
kill cap.
The dangerous shortcut would be to replace a slot on connect
unconditionally; the phone-slot rules above must gate it. A
legitimately displaced device simply reconnects.
A per-room failed-auth limiter. A room accepts at most 40 admission
attempts per 10-second window (counted in DO memory, reset on
hibernation). This bounds someone who knows the room name from grinding
auth attempts against an established room, each of which wakes the DO
and bills a request. The Worker may also run advisory per-IP and
per-room buckets ahead of the DO, with the per-isolate caveat below.
Strict room-name validation ahead of the DO. The Worker checks the
room header against ^[0-9a-f]{64}$ before addressing the DO, so a
malformed name never reaches DO storage, cardinality, or error paths.
Expiry. Pairing-mode state (challenges, tickets, registration
tokens) expires after 5 minutes, so a pairing that never completes
leaves nothing behind and the DO hibernates; there is no long-lived
pairing room. Established rooms carry the 30-day idle TTL described
earlier: kept alive by use, reaped only when unused, removed
immediately by an explicit delete-room at unpair.
What Cloudflare can and cannot know
Knows: both devices' IPs while they are connected, the room
pseudonym, traffic volume and timing, the App Attest key ids (one per
pairing), and the public verifier V (which authorizes nothing). The
room pseudonym is stable for the life of the pairing, so over time the
operator can build a per-pairing history of IPs and activity. Rotating
the pseudonym on a schedule (epoch-based names) was considered but
deferred.
Cannot know: message plaintext, session images, either device's
static identity key, the raw roomSecret, or the pid behind the
pseudonym.
Cannot do: impersonate either device, splice in a third party (the
Noise handshake fails), originate traffic, or mint a join (it holds
only V, and the signed transcript also binds the room and origin).
Can always do: deny service, globally or for a specific room, and
observe each room's traffic metadata. That is inherent to any relay;
BYO relay is the remedy for anyone who will not accept it.
Separately, Apple sees pairing cadence: generateKey and attest
contact Apple from the device, so one key generation per pairing is
visible to Apple (device, app id, timing). That is the cost of the
per-pairing keys. The optional fraud-assessment receipts (Abuse layer
3) would send Apple more.
Logging and retention commitments (hosted instance)
These describe what the operator controls. Cloudflare's edge keeps its
own connection metadata regardless, which no operator setting can
disable, so the claim is "we don't log," not "nothing is seen." BYO
relay is the remedy for anyone who needs more.
- No operator request logging: Workers logs, tail, and Logpush stay
off, and the operator persists no IPs anywhere. - The abuse counters (byte and frame quotas) are coarse, time-bucketed,
auto-expiring, and keyed by room pseudonym, never by IP. - An established room's record (its pseudonym, public verifier, and
opaque attest key id) is deleted on unpair, and otherwise reaped by
the 30-day idle TTL once the pairing goes unused, so an abandoned
pairing's record does not persist indefinitely. No IP or content is
ever part of it. - Traffic timing and volume are inherent to relaying; no padding or
cover traffic is added (any future ~100ms coalescing done for cost
reasons would help here only incidentally). - Both clients pin their HTTP User-Agent to a fixed string.
URLSession's default User-Agent would otherwise leak the app version
and OS build to the relay and Cloudflare's edge logs. - This covers the Worker's own behavior only. Cloudflare operates the
underlying platform (TLS termination, edge routing) and may log at
those layers entirely outside any operator's control, a self-hoster's
included; BYO relay changes who runs the Worker but still runs on
Cloudflare, so it does not escape that.
What each party stores
This consolidates what each party keeps at rest. The design above is
the source of truth; this is a quick reference.
- Phone. Keychain (this-device-only, non-synchronizable): the
phone's Noise static private key, the per-pairing App Attest key (in
the Secure Enclave), androomSecret. UserDefaults (non-secret): the
pairedpid, the Mac's Noise static public keyrs(from the QR),
and the relay URL. The join signing keypair is derived fromroomSecreton demand, not stored. - Mac. Keychain: the Mac's Noise static private key and
roomSecret. User defaults: the pairedpid, the phone's Noise
static public key (pinned at pairing), and the relay URL (an advanced
setting). The existing chat database holds chats, messages, and icons.
The join keypair is again derived fromroomSecret. - Relay room DO. The room name, the public verifier
V, the attest
key id, and coarse byte/frame counters keyed by room name. It never
stores plaintext, session images, any static key, the rawroomSecret, or thepid.
The asymmetry to notice: the room relay stores only public or
non-authorizing values, so a full dump of it reveals pseudonyms, public
keys, key ids, and traffic counts, and authorizes nothing, then or
later.
If a location is compromised
- Relay (dump or full takeover): pseudonyms, public verifiers, key
ids, IPs, and traffic metadata. It cannot read messages, cannot mint
a join (onlyVis stored), and cannot impersonate either device; it
can deny service and observe metadata. - Phone Keychain: the full pairing, the Noise static,
roomSecret
(hence the join key), and the attest key. This is the trusted device;
this-device-only, non-synchronizable storage confines it to the
physical phone (migration means re-pairing). - Mac:
roomSecret, the Mac static, and the chat history. It is the
peer the phone talks to and is expected to be trusted. - Photographed QR (no device):
rs,pid, and the relay URL,
which enable a remote pairing attempt, defeated by Mac-side SAS
confirmation, the QR's short validity, and the per-room handshake cap.
Abuse and cost control
The relay is a generic ciphertext pipe with an open-source protocol,
so it has to assume hostile clients. The defenses are layered. Layers 1
and 2, plus the admission hygiene above, apply in every configuration;
attestation (layer 3) hardens the hosted instance on top; and BYO relay
(layer 4) is the escape valve.
Lean on the free tier. Cloudflare's free plan has hard daily
caps rather than overage billing, so the worst case under attack is
"remote access unavailable until the caps reset," never a surprise
bill. Moving to the paid plan is a deliberate operator decision
later. The operator also enables Cloudflare's daily-cap alerts
(which need no request logging), so a sustained exhaustion attack is
distinguishable from "nobody is using remote access."DO-enforced quotas, sized so normal use never notices but
tunneling is worthless. Each room has a daily byte quota (512MB by
default, tunable); exceeding it tears the room down for the rest of
the 24-hour window. There are also frame caps, 500 frames per second
and 256KB per frame, enforced before splicing: a flood of tiny
frames is a CPU and billing attack that a byte quota alone misses,
since many small messages can bill as one request-equivalent. The
per-room counters double as the abuse signal.App Attest on the hosted instance (
ATTEST_REQUIRED): joining a
pairing room and registering a verifier both require App Attest
proof from a signed iTerm2 Buddy install. Several limits keep it
from being trusted too far:ATTEST_REQUIREDfails closed. Anything other than the exact
stringfalsemeans attestation is required, so one botched
config edit cannot silently open the relay. Only an explicitfalsedisables it (the BYO path).- The verifier checks the attestation's AAGUID, the field that
identifies which App Attest environment produced it, and on the
production instance rejects development-environment attestations
(appattestdevelop), accepting only production (appattest).
Skipping this would let any dev-signed build of the app pass during
the window Apple allows development attestations, a classic App
Attest mistake. Self-hosted instances configure which environments
they accept. - Attestation proves the install is the signed iTerm2 Buddy app on a
real Apple device, not that its operator is friendly, and an
attest key id is not a stable device identity:generateKeycan
be called any number of times, so an attacker running a signed
install can mint a fresh key id per request and walk through any
key-id-keyed quota. The real backstop is the per-IP rate limit on
the attestation endpoint (each new key costs an/attestcall),
with the WS upgrade limited per IP as well. Apple's
attestation-receipt fraud assessment, which scores per-device
key-generation count, could tighten this further but is not
implemented.
Per-IP limiting is coarse either way: it over-blocks many phones
behind one carrier-grade NAT address and under-blocks one phone
rotating addresses, so it is never trusted alone.Bring your own relay. Anyone can
wrangler deploythe same
Worker to their own free account (hard-capped, $0) and point the
Mac's relay setting at it; the QR'srelay=carries it to the
phone, so a self-hoster configures exactly one thing on one device.
Forks sign with their own team and cannot attest against the
official app id, so BYO relay withATTEST_REQUIRED=falseis the
supported path for them, not just a capacity escape valve. Each
pairing remembers which relay it lives on, so mixed setups stay
coherent.
Implementation notes
- The per-key-id assertion counter can race against itself when the
phone reconnects rapidly: two assertions are in flight and the
later-signed one is verified first. A counter rejection should be
retried with a fresh assertion, not treated as an attack signal. - App Attest keys cannot be deleted (Apple exposes no API for it), so
per-pairing keys leave orphaned Secure Enclave material behind across
re-pairs. This is harmless (each key is unusable once its Keychain
key id is gone), but worth knowing so it is not mistaken for a leak.
Floating Panes Design (`docs/floating-panes-design.md`)
Floating Panes: Design
Scope: Native sessions first. tmux integration is a second driver on the same model.
Primary files: PTYTab, SessionView, PTYSession, PseudoTerminal, MovePaneController, sources/tmux/
A floating pane is a terminal session that sits above a tab's tiled layout. It
is freely positioned, resizable, z-ordered, and overlaps the panes beneath it.
Floats are expected to be short-lived helpers, since they block the view of the
tiled panes, but they are full sessions and must survive an app relaunch.
This doc records the model, the product decisions made so far, the open
questions, and an inventory of existing features that need work. It is based on
a read-only audit of the code at commit dd65a6b9d and of the tmux source in~/git/tmux (which reports version next-3.9 and contains the 3.8 changes).
Nothing described here has been built or run. Line numbers are from that
commit and will drift; method names are the stable reference. Claims about
AppKit or tmux runtime behavior that were inferred from code are marked
"unverified".
0. Summary
The assumption being broken:
A tab's sessions are exactly the leaves of the split tree rooted at
PTYTab.root_, and those leaves tile the tab with no overlap, no gaps, and
no z-order.
The main conclusions:
- Build native floats first. tmux 3.8 floats then map onto the same model with
the server, rather than the user, owning geometry. - Every tab, native or tmux, gets a container view above
root_. Floats are
siblings ofroot_in that container (section 3.5). - Each float is a wrapper view that holds the outline, shadow and resize
handles, with a one-childPTYSplitViewinside it. The split view keeps the
many(NSSplitView *)view.superviewcasts in PTYTab valid. Users cannot
split a float. - A float's invariant is its grid (columns by rows), not its pixel frame.
- A native tab always has at least one tiled session. A tmux tab may have
none (section 12.5). - The whole feature is released together. The phases in section 13 order the
work; they are not separate releases. - Floats are in-window views. A live Metal pane is expected to composite
correctly over another; a spike confirms it (section 4). - Two tmux fixes are independent of this feature and should ship first
(section 12.2).
1. Decisions made
Scope and release
| Topic | Decision |
|---|---|
| Name | “Floating pane”. Reusing the adjective from “floating hotkey window” is fine. |
| Order of work | Native first, tmux second. The feature is not worth doing for tmux alone. |
| Release | Everything ships together. No intermediate state reaches users. |
| Refactor safety | During the container refactor -[PTYTab sessions] is renamed so that every caller must choose between all sessions and tiled sessions at compile time. It is renamed back afterward. |
Model
| Topic | Decision |
|---|---|
| Container | Floats are in-window views. Two Metal panes are assumed to composite correctly. If they do not, Metal is turned off for floats. |
| Metal pane cap | Floats count toward the six-session limit above which a tab stops using Metal. The limit is there for performance. |
| Older builds | Saved arrangements need not stay readable by older builds. Downgrading is not supported. |
| Splits inside a float | Not supported natively. Split commands are disabled when a native float is active. The container and schema leave room to add them later. |
| Split aimed at a float | A split that reaches a native float from a caller that cannot be disabled goes to the tab's most recently active tiled pane instead. |
| Tiled to float conversion | No direct command. “New float” and “dock to tiled” cover it, and swapping a tiled pane with a float exchanges their roles. |
| Swap | A tiled pane can be swapped with a float, and two floats can be swapped. Each takes the other's place, frame and z-order. |
| Session order | Tiled sessions come first and floats after, wherever sessions are numbered or indexed. |
| Last tiled pane leaves | Native tabs only. If the last tiled pane leaves the tab by any route (its session ends, it is docked into another tab, dragged to the tab bar or to empty space, moved with a Move Session item, or buried), the front float is promoted to be the tab's tiled pane. In a tmux tab nothing is promoted and the tab becomes floats-only, as tmux does. Other floats stay floating. Closing the float implicitly is not an option, since it may be doing something important. Promotion resizes the float's grid to fill the tab. |
| Undo after promotion | If closing the last tiled pane promotes a float and the close is then undone, the pane returns as a new tab. Not worth special handling. |
Creation, size and position
| Topic | Decision |
|---|---|
| Creation | The menu item “New Floating Pane with Current Profile” comes first. The other creation points (section 6.1) follow in step 9 of the plan. |
| Initial position | A new float is centered in the tab. |
| Initial size | 80% of the tab's size in each dimension. To be revisited after feedback; a setting may follow. |
| Minimum float size | 2x2 cells plus a fixed size in points for the chrome. It exists only to prevent disaster, not to guarantee a usable float. |
| Tab minimum size | Floats contribute to the tab's minimum size. Each contributes its minimum grid at its own font plus its chrome, not its current grid. A float with a larger font than the tiled panes can be what constrains it. |
| Maximize | Natively, floats stay visible over a maximized pane. Maximize never hides a float, so the hide toggle is the only way a float is hidden. tmux tabs follow the server (section 12.4). |
| Keyboard resize and move | The existing menu items that resize a split pane apply to a float. New menu items move a float. |
| Window escape sequences | From a native float, CSI t treats the pane as the window: resize and raise or lower act on the float, reports describe the float, and move and miniaturize are ignored (section 6.9). |
Appearance
| Topic | Decision |
|---|---|
| Chrome | A float always has a title bar, a one-point outline and a shadow. Its corners are square. |
| Translucent floats | A float whose session is translucent gets an NSVisualEffectView underlay with within-window blending, so it shows a blurred version of what is beneath it and stays legible. It does not show the desktop. |
| Shared background image | A float draws its slice of the shared image like any pane. The chrome separates it from the layout. |
| Dimming | Floats are dimmed like any split pane. With “dim inactive split panes” on, an inactive float is dimmed, and focusing a float dims the other panes in the tab. |
| Accessibility | VoiceOver reads tiled panes first and floats after, matching session order. Covered panes are still announced, since their sessions are running. |
| Covered cursor | A float may cover the active tiled pane's cursor. The user can hide the floats. |
Focus and z-order
| Topic | Decision |
|---|---|
| Raising | Only a click, “Bring to Front”, creating a float, and reveal change z-order. Focus follows mouse changes focus without restacking. Keyboard navigation changes focus without raising. |
Hide and show
| Topic | Decision |
|---|---|
| Hide and show | There is a toggle that hides all floats in a tab while their sessions keep running. |
| Focus when floats are hidden | If the active session is a float when floats are hidden, focus goes to the next most recently used session in the tab that is still visible. |
| Navigation to hidden floats | Next Pane, Previous Pane and cmd-opt-N can land on a hidden float. Focus entering a float unhides the floats. |
| Broadcast input to hidden floats | Same policy as a maximized tab, whose hidden panes still receive broadcast input today. Hidden floats receive it. |
| Hidden floats indicator | Hidden floats are shown with the existing indicators feature (iTermIndicatorsHelper), like the maximized-pane indicator. |
| Hidden float activity | The hidden-floats indicator is decorated the way a tab's icon is, with the dead, bell, new-output and idle states of the hidden floats. Nothing unhides. |
| Hidden float ends | A hidden float whose session ends follows its profile. With close on end it disappears without unhiding anything. |
Docking and dragging
| Topic | Decision |
|---|---|
| Dock | A float can be docked into the tiled layout by menu item or by drag. |
| Dock gesture | Dragging a float over a tab keeps it floating by default. Holding Control shows the split halves for docking. Control is not used by any existing drag-start gesture. |
| Float to an existing tab | The tab bar gains spring-loaded tab selection when a pane drag hovers over a tab. |
Lifecycle
| Topic | Decision |
|---|---|
| Session end | A float follows its profile's end-of-session setting, like any pane. |
| Relaunch | Floats come back as floats. Upgrading with Sparkle must not lose state, including running sessions in floats. |
| Duplicate Tab | High fidelity: floats are duplicated with their geometry and z-order. |
tmux
| Topic | Decision |
|---|---|
| tmux idioms | In tmux tabs, follow tmux's behavior where it differs from native, for example Split on a float creates another float. |
| tmux float position | A tmux float is positioned so its top-left cell lines up with the matching cell of the tiled pane it overlaps. |
| tmux float chrome | Chrome goes in the one-cell border tmux reserves around a float. The normal title bar is used, and if it does not fit it is clipped. A title bar never covers its own float's content. A float tmux draws without a border has an outline over its content's edge and a title bar that is shown and hidden with a toggle, like the stoplight hotbox, and appears above the float. Floats at negative offsets are clipped as in tmux. |
| tmux floats-only windows | Supported. A tmux tab may have no tiled panes. Native tabs keep the invariant through promotion. |
| tmux popups | No popup-specific work. tmux has removed popups; display-popup is now an undocumented compatibility command that opens a modal floating pane. A modal float is treated as an ordinary float. |
| tmux before 3.8 | No floats. “New Floating Pane” is disabled in tmux tabs on older servers. Native and tmux sessions cannot mix in a tab, so drags are unaffected. |
2. Open questions
None at present. Items that still need a look, rather than a decision, are
marked "unverified" where they appear.
3. Model
3.1 Container
PTYTab gains an ordered collection of floats, back to front. Each float is a
wrapper view containing a PTYSplitView with exactly one SessionView child.
The wrappers are siblings of root_ in the tab's container view (section
3.5), above it. The float's frame and z-order belong to the wrapper.
The wrapper is needed because NSSplitView lays its subviews out to fill it,
and PTYTab's splitView:resizeSubviewsWithOldSize: (PTYTab.m:6595) does the
same. A split view has no room of its own for an outline, a shadow or resize
handles. The wrapper owns those (section 4.3) and insets the split view by the
outline width.
Why a one-child split view rather than a bare SessionView:
- The split, peer-swap, synthetic-swap and divider paths all cast
view.superviewtoNSSplitViewand then use generic calls
(replaceSubview:with:,adjustSubviews). With a per-float split view those
casts stay true, and Instant Replay, Filter and screenshot-mode swaps inherit
the float's frame and z-order with no extra bookkeeping. - Of 392 methods in PTYTab.m, 68 reference
root_and 39 are already
parameterized on a node (_recursiveSize:,_recursiveMinSize:,adjustSubviewsOf:, thesplitView:delegate methods,_recursiveEncodeArrangementForView:,_recursiveRestoreSplitters:). - The
root_identity tests that must become "is a root" are few:assert(parentSplit == root_)insplitVertically:newSession:before:targetSession:
(PTYTab.m:2499), three cases incleanupAfterRemove:(2091, 2129, 2137),_recursiveViewFrame:(2162, which walks up superviews until it meetsroot_and would not terminate under another root), and the enumerators.
Float records must be keyed on the float's wrapper or on viewId, never on
the SessionView pointer. During a synthetic swap the live view is parked inhiddenLiveViews_ with no superview.
Known hazards for the floats in the container:
setRoot:(PTYTab.m:5212) replacesflexibleView_'s subviews and re-adds
only the root. It runs on maximize, unmaximize and tmux layout rebuilds, and
would drop the floats.iTermFlexibleView drawRect:(iTermFlexibleView.m:48-52) punches a clear
hole for every subview frame, so it would punch one for each float.unmaximizeTemporarilyAndActivate:(PTYTab.m:1187) andsetActiveSessionPreservingMaximization:replaceroot_.subviews[0]with
the target session's view while maximized. With a float as the target they
would move the float intoroot_.- Nothing on the tab content path sets
clipsToBounds. Floats are clamped to
the tab natively, so clipping matters mainly for tmux (section 12.4).
3.2 Invariants
- A native tab always has at least one tiled session. A tmux tab may have
none, because tmux allows a window with only floats (section 12.5). - A float holds exactly one session.
- Floats sit above all tiled panes. Among floats, order is explicit.
3.3 Enumeration
-[PTYTab sessions] (PTYTab.m:1668) walks root_, or returns idMap_ values
while maximized. Nearly every feature in the app reaches panes through it or
through -[PseudoTerminal allSessions].
Decision: sessions and sessionViews include floats, and a newtiledSessions serves the callers that must not see them. Each existing call
site needs to be classified. To make that exhaustive, rename sessions for
the duration of the refactor so that every caller fails to compile until it
picks one, then rename it back. There are 33 sessions.count sites outside
PTYTab.m alone. Known callers that need tiledSessions:
- Pane title visibility, which uses
sessions.count > 1in six places
(PTYTab.m:612-627, 4386, 4483, 5434, 5748; PseudoTerminal.m:8183). - "Last pane" logic:
closeSession:soft:(PseudoTerminal.m:1813),closeSessionWithConfirmation(2755),sessionRemoveSession:(PTYTab.m:7541),removeAndClearSession(MovePaneController.m:173),reallyDropTab:(197),
the tab-bar drop edge case (PseudoTerminal.m:8757), and "drop on self moves
the window" (PTYTab.m:7437). - Tab size:
size,currentSize,variableTmuxSize(PTYTab.m:4718) andsessionsAtTop/Left/Right/Bottom(1400).minSizemeasures the tiled tree
and then adds a term for floats (section 5). apply_layout: the resolver, environment and mutator
(iTermLayoutResolver.swift:152-162, iTermLayoutEnvironment.swift:49-55,
iTermLayoutMutator.swift:276-283).recreateTab:(PseudoTerminal.m:11271), which requires every live session to
appear in a saved arrangement.replaceViewHierarchyWithParseTree:(PTYTab.m:5410), which terminates every
session left insessionsafter rebuilding the tiled tree.- Menu validation:
hasMultipleSessions(PTYTab.m:3048) countsroot_
subviews while other items usesessions.count; they must agree.
Callers that count sessions and still need a decision:
moveSessionToTab:validation (PseudoTerminal.m:14070) and select-pane
validation (14192).restorableSessionForSession:(PseudoTerminal.m:2507), which chooses
between restoring a session and restoring a tab.- The inline progress bar (PseudoTerminal.m:9279).
textViewEnclosingTabHasMultipleSessions(PTYSession.m:21890), used by the
context menu.textViewSplitPaneWidthIsLocked:(PTYSession.m:22050).- The tmux deregister count (PTYSession.m:3970).
- The pane-move split (MovePaneController.m:281).
arrangeSplitPanesEvenly:validation (iTermApplicationDelegate.m:569).
While a tab is maximized, sessions returns only idMap_ values. Floats stay
outside idMap_ and savedArrangement_, so both accessors need to add floats
in the maximized branch as well. Broadcast input relies on this: its domain iscurrentTab.sessions (PseudoTerminal.m:16090), which is why hidden panes of a
maximized tab receive broadcast input today.
3.4 Geometry drivers
Enumeration, z-order, focus, chrome and clipping are shared. Geometry ownership
differs:
- Native: the user owns the frame. The grid is canonical and the frame is
derived from it. - tmux: the server owns a cell rectangle. iTerm2 derives a frame from it
and sends commands to change it.
Build this seam first so tmux is a second driver, not a second implementation.
3.5 Tab container view
Today a native tab's root_ is the tab view item's view: setRoot: setstabView_ = newRoot (PTYTab.m:5223-5227). Only tmux tabs interpose a view,flexibleView_, so that the root can be smaller than the tab. Floats need a
common superview, so every tab gets the interposed container and root_ is
always a child of it. The places that assume otherwise:
Ownership of the tab view item's view (PTYTab.m)
setRoot:(5212-5227). The native branch makes the root the tab's view. The
tmux branch wipes all offlexibleView_'s subviews.enableFlexibleView(3562-3574) andnotifyWindowChanged(3577-3582),
which create the container lazily and for tmux only.setTabViewItem:(1311) andreplaceWithContentsOfTab:(3515), which movesroot_subviews between tabs.
flexibleView_ != nil used to mean "is a tmux tab"
These must test isTmuxTab once every tab has a container:
copyWithZone:(570),updateFlexibleViewColors(1247),rootViewSize
(5270),updatedTmuxLayoutRequiresAdjustment(5614) and the comparisons of
root and container size at 5619-5641.- The
hasFlexibleView:parameter chain: PTYTab.h:143, PTYTab.m:3636 and
4473, PseudoTerminal.m:2848, 4403, 11335, 11378, 11402, 12244 and 14701.
Sizing that treats root_'s frame as the tab's frame
setSize:(2817-2826). The native branch resizesroot_directly and the
tmux branch resizestabView_. Native must size the container and have the
root follow.- The autoresizing mask set at 5224 is max-margin only. That works today
becauseNSTabViewsizes the item's view itself. Inside a container a native
root will not track it. currentSize(2809),recompact(2980),absoluteFrame(727-732),image:(2898), and the reads at 4711, 4933, 5125 and 5703.
Root identity tests and superview walks
- The cases listed in section 3.1, plus the tests at 2061 and 2185.
replaceWithContentsOfTab:(3533), which skipsroot_when moving
delegates._ancestorsOfLockedSession(6432-6440) andtextViewSplitPaneWidthIsLocked:(PTYSession.m:22055-22057). Both stop on a
nil superview, so they are safe, but from a float they never reachroot_.
Coordinates relative to root_
rootRelativeOriginOfSession:(1080), thesessionsAtTop/Left/Right/Bottom
comparisons (1405-1439) and directional navigation (1492-1588). These still
convert correctly for a float.sessionContainerView:(7630) returnsroot_and anchors the shared
background image (section 4.2). It should return the container.- In a tmux tab the root can be smaller than the container. Float frames are
relative to the container, not the root.
Outside PTYTab
- The swipe handler reparents
realRootView(PseudoTerminal.m:16456-16463 and
16509-16510). It works ifrealRootViewreturns the container. - iTermSplitTreeRebuilder.swift:146 sizes a new root from
realRootView.bounds. imageFromSelectedTabView:tabViewItem:(PseudoTerminal.m:8062) uses the tab
item's view and will start to include floats, which is the desired result.- The window-arrangement encoder (PseudoTerminal.m:4689) and
beginDrag:
(MovePaneController.m:334) readsession.view.superview.
This list covers the references that depend on where root_ sits in the view
hierarchy. The 68 methods that reference root_ were not all traced.
4. Rendering
4.1 Metal over Metal (confirmation spike)
Two live Metal panes have never overlapped. Splits tile, and maximize gives
Metal to the active session only (PTYTab.m:7321-7324). Pane chrome already
composites above the CAMetalLayer, which is encouraging but not proof.
Spike: put one opaque Metal SessionView over another and confirm both stay
live and composite in view order. Also check, by eye, a translucent float with
its blur underlay (section 4.2) over a live Metal pane during a float drag and
during a resize. The blur is sampled by the window server and needs no code,
but the per-resize bounce to the legacy renderer (below) could make the
blurred region lag or pop.
In-window views are the decision (section 1), and the spike is expected to
pass. If it fails, Metal is turned off for floats. updateUseMetal is
all-or-nothing per tab today, so that means either a tab uses the legacy
renderer while it has a float, as a tab with a browser pane already does
(metalAllowed: returns NotATerminal at PTYSession.m:9450), or Metal becomes
a per-session choice within a tab, which is new work.
Other Metal facts that apply either way:
updateUseMetal(PTYTab.m:7247-7327) requires every session to passmetalAllowed:. A float with a zero or oversized frame, or with no window
yet, turns Metal off for the tab (PTYSession.m:9463, 9486, 9554).- Metal is disabled at six or more sessions (PTYTab.m:7271). Floats count.
- A drag that shows drop targets disables Metal (PTYSession.m:9498).
SessionViewbounces to legacy for a runloop turn on each size change
(SessionView.m:1434). Quantizing float resizes to whole cells limits this.
4.2 Opacity and background images
Nothing opaque sits behind a pane (SessionView.m:559-578). Without an
underlay, a translucent float would show the tiled pane's glyphs and every
lower float through it.
Blur underlay. A float whose session is translucent gets anNSVisualEffectView in its wrapper, beneath the split view.
- Blending is within-window, so the float shows a blurred version of the tiled
panes, lower floats and shared image beneath it. Behind-window blending
would punch through to the desktop instead. - It lives in the wrapper, not in
SessionView, so it survives Instant Replay
and Filter swaps. - It is installed only while the session is translucent, and updates when the
profile's transparency changes or the window's “Use Transparency” toggle
flips. - Its appearance is forced from the profile's background color (dark
background, dark appearance). The material is otherwise tinted by the system
appearance and would wash out a dark terminal in light mode. - Its state is always active, like the existing ones, so floats do not go flat
when the window loses key. - Precedent for a within-window effect view over terminal content: the hover
URL view (SessionView.m:79-88), the minimal composer and announcements.
Consequences:
- A translucent float no longer shows the desktop.
- The profile's blur setting and blur radius do not apply to a float. The
material's blur is fixed and is on even when the profile's blur is off. - Tiled panes keep the window-level blur of the desktop, so the two kinds of
pane look slightly different. - “Reduce transparency” in System Settings makes the material opaque with no
work on our side. - Transparency adds no new Metal rule. A translucent session already forces
its tab to the legacy renderer unless text is monochrome
(PTYSession.m:9528-9541).
Shared background image. With per-pane background images off, one image
sits behind everything (PseudoTerminal.m:16020-16033) and each session draws
its own slice. The slice is computed from the pane's position, not from the
split tree: the Metal path passes the pane's frame as a fraction of the
window's content view plus a container rectangle (textViewRelativeFrame andtextViewContainerRect, PTYSession.m:13426-13450) toiTermBackgroundImageRenderer, and the legacy path does the equivalent iniTermBackgroundDrawingHelper. A float is another rectangle and draws its
slice the same way. What needs handling:
sessionContainerView:must return the tab container rather thanroot_
(section 3.5), or a float outside a small tmux root computes a slice outside
the image.- The legacy path redraws only when asked. A live move must invalidate the
float at each step, or the image slides with the float and then snaps back.
Metal recomputes the slice every frame. - A float with a blur underlay skips its own slice. Otherwise it draws a sharp
slice over a blurred copy of the same image. - The shared image is the current session's (PseudoTerminal.m:15994-16000).
Focusing a float whose profile has a different image swaps the image for the
whole window, as it does for split panes today.
Also:
- Window blur is a window-level effect and does not blur the tiled pane behind
a float. blur(PTYTab.m:3081) and the flexible view color (1242) read the active
session only.
4.3 Chrome
Title bar. A float always shows a title bar, regardless of the pane-titles
setting. It is the grab handle. The existing SessionTitleView is reused. It
already has the close button, menu button, lock button, pane number, title
dimming, double-click handling and hosting for a per-pane status bar, and it
is inside SessionView, so the grid-to-frame arithmetic already accounts for
it. Changes:
_showTitleis perSessionView(SessionView.m:139) but the tab sets it
fromsessions.count > 1. The rule becomes "is a float, or the tab has more
than one tiled pane". It must also apply to the synthetic view during
Instant Replay and Filter swaps.mouseDragged:(SessionTitleView.m:377) starts the pasteboard drag at once.
A float needs the live move first (section 7).- The menu button gains the float items from section 6.7.
- Double-click maximizes the float.
Outline and shadow. These belong to the wrapper (section 3.1).
- A one-point border and a shadow, with an explicit shadow path equal to the
wrapper's bounds so it stays cheap during live moves. - Corners are square. Rounded corners would require masking the Metal layer.
- The border color is the focus cue: the accent color when the float is
active and a neutral separator color otherwise. Overlapping panes then have
a focus indicator that does not depend on the dimming setting. iTermActivePaneBorderViewstays off for floats. It is drawn for the active
session only, inside the scroll view's frame, and rounds corners at window
edges (SessionView.m:2508-2606).- A float pushed into a rounded window corner has a square corner there, and
the window clips a few pixels of its border. Accepted. The alternative is to
inset the clamp by the window's corner radius.
Resize handles. An invisible band of about four points around the outline,
inside the wrapper, with cursor rects. It overlaps the shadow, so it costs no
visible space. Being outside the SessionView bounds keeps it clear of
mouse-reporting apps, selection starting at the edge, the scroller, and
browser panes that swallow mouse events. The terminal margins are not grab
zones. Clicks in the band go to the float, not to the pane beneath.
5. Sizing
Today geometry flows from the window to the split tree to each SessionView
frame to rows and columns. sessionSizeForViewSize: (PTYTab.m:2983) derives
the grid, and -[SessionView compactFrame] (SessionView.m:2354) is the exact
inverse. Only the ioctl is throttled, to one per 0.2 seconds
(WinSizeController.swift:179-246).
Rules for floats:
- The grid is canonical. The frame is
compactFramefor the grid, which
includes the title bar, plus the wrapper's outline inset. Only a
user edge-drag goes from frame to grid, snapped to whole cells. Otherwise the
many callers offitSessionToCurrentViewSize:(font, margin, scrollbar,
title and toolbar changes) would silently cost the float rows. - Window and tab resize. Keep the grid. Clamp the position and preserve
edge anchoring, so a float touching the right or bottom edge stays there.
Shrink the grid only when the tab is smaller than the float, and remember the
desired size so it returns when there is room. Proportional scaling would
resize the PTY on every drag step.fitTabsToWindowruns for every tab, so
the clamp must run for background tabs too. - Tab size. Floats never contribute to
sizeorcurrentSize. Otherwise
they inflate window refits. - Tab minimum size. Floats do contribute to
minSize. A float with a
larger font than the tiled panes can be the constraint. Each float
contributes its minimum grid at its own font plus its chrome, not its
current grid, so a large float does not block shrinking the window. The
shrink-and-remember rule above covers the range in between.windowWillResize:toSize:enforcesminSizefor every tab (PseudoTerminal.m:5951), so hidden floats and floats
in background tabs count. - Session-initiated resize.
sessionInitiatedResize:(PseudoTerminal.m:7220)
locks the session and refits the window. It is reached from CSI 8 t, DECCOLM,
the APIgrid_sizesetter, AppleScript, Instant Replay frames, the Companion
app's resize, and the Increase/Decrease Height and Width actions. For a float
it must set the grid, set the frame, and clamp, with no window change. The
same profile gates apply, and it is allowed in full screen. The exception
is the Increase/Decrease Height and Width actions, which are window
commands: with a float active they resize the window through a tiled
session (section 6.8). Section 6.9 lists the gates in this path that a
float must bypass. - Font size change. Under “adjust window when changing font size”, grow the
float to keep its grid, clamped to the tab. With the setting off, keep the
frame.reallyChangeSessionFontSize:(PTYTab.m:2240) refits the window today. - No overhang. A native float is clamped inside the tab. Nothing native
forces overhang, and the title bar must stay reachable. - Minimum and maximum. Minimum is 2x2 cells plus a fixed size in points
for the chrome. That is not a usable float; it only prevents a degenerate
frame. tmux allows 1x1 and its floats are shown at whatever size the server
gives. Maximum is the tab's bounds. The split view constrain callbacks do
not apply to floats. - Initial size. 80% of the tab's width and height, rounded down to whole
cells of the float's font. Pass it explicitly tosetupSession:withSize:.
The profile's columns and rows are not used. - Initial position. Centered in the tab.
- Extra chrome.
rightExtra(100 points for adjacent timestamps, plus
gutter panels) and the legacy scroller widen aSessionView. Natively the
frame simply includes them. - Maximize. Floats stay visible over a maximized tiled pane. Creating or
revealing a float does not unmaximize. Maximizing a float sets its frame to
the tab bounds and remembers the old frame, without theidMap_root swap
or an arrangement round trip. A maximized float moves behind the other
floats, which stay visible above it, as tmux does when a float is zoomed
(section 12.1).
6. Interaction
6.1 Creation
First version: “New Floating Pane with Current Profile”.
Places that encode where a new session goes, for step 9 of the plan:
iTermOpenStyle(SessionCreation/iTermOpenStyle.h)- New-instance open style (
stylePreference <= 3, iTermApplicationDelegate.m:871-885) - Key actions 26-29 and their four synchronized lists
- Pointer actions (
kNew*SplitWithProfilePointerAction) - Profiles window (
HORIZONTAL_PANE,VERTICAL_PANE) and the Toolbelt profiles tool - Context menu split items, including the URL split items
- API
SplitPaneRequest, AppleScriptsplit, the orchestrator'sSpawnWindowChoice - Browser split actions
6.2 Moving and resizing
- A plain title-bar drag moves the float live, clamped to the tab. The grid is
unchanged, so there is no PTY resize. - Mouse-down on a float raises it. This needs its own code: a click makes a
pane first responder on mouse-up, and a cmd-click never does
(PTYMouseHandler.m:577-585). If raising only followed activation, a float
would rise on release, and a drag-selection in a background float would
happen while it was still behind. - Edge and corner drags resize, snapped to cells, with a size readout. There is
no size readout on divider drags today, only on window resize. - The existing menu items that resize a split pane (the four Move Divider
actions) apply to a float and resize it by one cell. - New menu items move a float by keyboard (section 6.7).
- “Lock Layout” blocks float move, resize and docking. Window “Lock Size” does
not.
6.3 Focus and z-order
setActiveSession:(PTYTab.m:937) only makes the text view first responder.
It stays that way: becoming active does not raise a float. A float is
raised by a click (section 6.2), by “Bring to Front”, when it is created,
and by reveal. Focus follows mouse and keyboard navigation change focus and
leave the z-order alone, so the active float can be partly covered by
another float.- In a tmux tab the server owns z-order and raises a float when it becomes
the active pane (section 12.4). iTerm2 follows the server there. -[PTYSession reveal](PTYSession.m:16806) never raises anything and, when
the tab is maximized, callssetActiveSessionPreservingMaximization:, which
replacesroot_.subviews[0]with the target view. Reveal must unhide floats
if hidden, raise the target, and never move a float intoroot_.- When the floats are hidden while a float is active, activate the next most
recently used session in the tab that is still visible. Sessions already
carry a per-tabactivityCounter(PTYTab.m:944), which gives the order. - A float may cover the active tiled pane's cursor. That is accepted; the
user can hide the floats. - When the active float closes, activate the next float in z-order, else the
last active tiled session.nearestNeighborOfSession:(PTYTab.m:2261) uses
the superview's subview index and returns nil for a lone float.
6.4 Navigation and numbering
orderedSessions(PTYTab.m:1087) has two orderings. WithnavigatePanesInReadingOrderon it sorts by origin; floats then sort after
the tiled panes, in z-order, because overlapping origins make reading order
meaningless. With it off it sorts byactivityCounter, and floats take
part like any pane.- While maximized, the reading-order branch builds the list from
_orderedSessionIDs, which comes fromidMap_(PTYTab.m:1089). Floats are
not inidMap_, so with a float activesessionInDirection:finds no
index and Next Pane silently does nothing. The list must add floats. - Pane ordinals (cmd-opt-N, title bars, the Toolbelt status tool) follow that
order. - Next Pane, Previous Pane and cmd-opt-N include hidden floats. Landing on one
unhides the floats. - Directional select and swap (PTYTab.m:1489-1604) use a strict non-overlap
test. Floats are excluded as candidates. From a float they do nothing. - Menu validation for these items must count consistently (section 3.3).
6.5 Hide and show
A per-tab toggle hides all of the tab's floats. Sessions keep running.
- One rule covers unhiding: focus entering a float unhides. Reveal, keyboard
navigation, creating a float, and dropping a float into the tab all do this. - Hiding while a float is active moves focus (section 6.3).
- Hidden floats still receive broadcast input, as the hidden panes of a
maximized tab do today (section 3.3). - Hidden floats still count toward the tab's minimum size (section 5) and
toward close confirmations. - The hidden state is saved in arrangements (section 10.1).
- A hidden float whose session ends follows its profile. With close on end
it disappears and nothing unhides. - Output, a bell, or a session that ends and stays open never unhides. They
decorate the indicator (below).
Indicator. Hidden floats use the existing indicators feature, the icons
drawn at the top right of a terminal by iTermIndicatorsHelper. The
maximized-pane indicator (kiTermIndicatorMaximized) is the model.
- Add an indicator identifier with an SF Symbol, an entry in
sequentialIndicatorIdentifiers(iTermIndicatorsHelper.m:303) and help text
(377).configureIndicatorsHelperWithRightMargin:(PTYTextView.m:2348)
sets it from a new delegate query. The Metal renderer draws indicators from
the same helper. - It shows in every tiled pane of a tab that has hidden floats. Indicators
are per session, and there is no tab-level place to draw one. Showing it
only in the active pane would make it jump around. - Indicators are images with no count. The hover help text can give the
count, sincehelpTextForIndicatorWithName:sessionID:is computed per
session. That string needs plural handling perdocs/localization.md. - Indicators are not clickable. Showing the floats is done from the menu or
its shortcut. - Toggling hide must redraw every tiled pane in the tab.
- The indicator is decorated the way a tab's icon is.
updateIcon
(PTYTab.m:798) picks one state by priority: status dot, dead, bell, new
output, idle. The indicator shows the highest-priority state among the
hidden floats, using the same images and the same “show new output
indicator” setting. The state clears when the floats are shown. - Indicators are single images today. The decoration needs either composed
images or a small badge drawn byiTermIndicatorsHelper. - Indicators are drawn by
PTYTextView. A tab whose tiled panes are all
browser panes has nowhere to show it. - In a tmux tab the same indicator covers floats that the server hides under
zoom. tmux has no count or flag for that, so iTerm2 derives it by comparing
the unzoomed layout with the visible layout (section 12.4).
6.6 Split, close, dock
- Split items are disabled when a native float is active. The validation
path (canSplitPaneVertically:withBookmark:) is called from one place and
tests the current tab's active session, not the target session, so many
callers reach a split without it. Section 6.9 lists every entry point,
which ones are refused and which are redirected to a tiled pane. - Split selection mode (
setSplitSelectionMode:excludingSession:move:,
PseudoTerminal.m:12350) puts an overlay on every session inallSessions.
It drives the keyboard “Move Session to Split Pane” and swap flows. For a
move, floats must not be offered as targets, and they must be hidden or
faded while the mode is on so they do not cover real targets. For a swap,
floats are valid targets and stay visible (section 6.9). The API'siTermSessionPickeruses the same overlays. - Closing a float closes only the float. The "last session closes the tab"
checks must not fire. - “Dock Floating Pane” moves the float into the tiled layout.
6.7 Menu items
New: “New Floating Pane with Current Profile”, “Dock Floating Pane”,
“Show/Hide Floating Panes”, “Bring to Front”, “Send to Back”, and four items
that move the active float up, down, left and right. Strings followdocs/localization.md.
Moving a float to another tab or window uses the existing Move Session items
(section 6.8). The float's title bar menu and the context menu are one menu
(menuAtCoord:, iTermTextViewContextMenuHelper.m:382) and offer the float
items too. That menu validates only pane and layout locks today and needs
float-aware validation for Split, Move, Swap and Bury.
6.8 Main menu audit
Every item in MainMenu.xib was considered. Items not listed act on the current
session or on the app and work unchanged in a float.
Shell
| Item | Effect |
|---|---|
| Duplicate Tab, Duplicate Window | Floats are duplicated with geometry and z-order (section 1). |
| Split Horizontally and Vertically, with and without the sheet | Disabled when a native float is active (section 6.6). Enabled on a float in a tmux tab, where the server creates another float. tmux tabs follow tmux's idioms. |
| Close | Closes only the float. Blocked by Lock Layout, like any pane (closeSessionWithConfirmation:). |
| Close All Panes in Tab, Close Terminal Window | Include floats, hidden ones too. Confirmation counts must include them. |
| Undo Close | Section 10.4. |
| Save Archive of Current Session | Uses the only-session arrangement, which omits floats (section 10.1). Unaffected. |
| Exit Workgroup | Disabled for floats, which cannot enter a workgroup. |
| Broadcast Input to All Panes in Current Tab | Includes floats, hidden ones too. |
| Show Background Pattern Indicator | Stripes composite the way background images do: each pane draws its own, and a float's cover what is beneath it. No work expected. |
| tmux > Pause Pane | Per pane. Works for a float once floats are tmux panes. |
Edit
| Item | Effect |
|---|---|
| Find Globally | Reveals its match, which unhides and raises (section 6.3). |
| Filter, Clear Instant Replay | Synthetic swaps inside the float (section 9). |
| Alert on Marks in Offscreen Sessions | "Offscreen" today means a pane hidden by maximize, or a window or tab that is not showing (PTYSession.m:19278-19286). The maximize test treats every non-active pane as offscreen, which is wrong for floats that stay visible over a maximized pane. Hidden floats must count as offscreen. |
View
| Item | Effect |
|---|---|
| Toggle Full Screen, Show Tabs in Fullscreen, Show Toolbelt | Change the tab's size. Floats are clamped (section 5). |
| Use Transparency, Disable Transparency for Active Window | Install or remove each float's blur underlay (section 4.2). |
| Zoom In on Selection, Zoom Out, Start Instant Replay | Synthetic swaps inside the float. |
| Find Cursor | Draws in its own overlay window at the cursor's screen position. If a float covers the cursor, the floats are hidden temporarily while Find Cursor is showing and restored afterward. This is not the hide toggle: it does not move focus, change the indicator, or get saved. |
| Show Timestamps | Widens the SessionView. The grid is canonical, so the float grows and is clamped. |
| Show Composer | The minimal composer needs about 217 points and behaves as in a narrow split. |
| Maximize Active Pane | Section 5. |
| Make Text Bigger, Normal Size, Smaller | Section 5, font size change. |
| Restore Text and Session Size | Also resets the grid, through sessionInitiatedResize:. For a float that sets the float's grid with no window change. |
| Open Quickly | Reveal. |
Session
| Item | Effect |
|---|---|
| Edit Session | A profile change can alter font, transparency and title. The float's frame, underlay and chrome must follow. |
| Open Autocomplete, Command History, Recent Directories, Paste History | Separate windows placed at the cursor. They ignore in-tab z-order (section 9). |
| Move Session to Window, Move Session to Tab | Work for a float; it becomes a tiled pane at the destination. There are no separate float-specific items. Their validation counts sessions (PseudoTerminal.m:14070) and must count correctly with floats. Moving the last tiled pane promotes a float natively and leaves a floats-only tab in tmux. |
| Move Session to Split Pane | For a float this is the keyboard way to dock: split selection mode picks the target pane. Floats must not be offered as targets (section 6.6). |
| Make Screenshot | Captures the window, so it includes floats. |
| Bury Session | Works for a float. Burying the last tiled pane promotes a float. Disabled for tmux floats (section 12.6). |
| Buried Sessions | Disinter goes through recreateTab: (section 10.4). |
Window
| Item | Effect |
|---|---|
| Zoom, Window Style, Arrange Windows Horizontally | Change the window's size. Floats are clamped. |
| Save and Restore Window Arrangement, Load Arrangement from File | Include floats (section 10). |
| Change Profile in Arrangement | Rewrites sessions in a saved arrangement and must reach float sessions (section 10.2). |
| Move Tab to New Window, Merge All Windows | The tab carries its floats. Clamp after the destination refits. |
| Select Pane Above, Below, Left, Right | Floats are not candidates. From a float they do nothing and should be disabled (section 6.4). |
| Next Pane, Previous Pane | Include floats, hidden ones too (section 6.4). |
| Previous Peer, Next Peer | Disabled for floats. |
| Move Divider Up, Down, Left, Right | Resize a float by one cell: right and left change the width, down and up change the height. moveCurrentSessionDividerBy:horizontally: (PTYTab.m:5848) casts the session view's superview and that view's superview to split views. For a float the second is the wrapper, so floats need their own branch. The titles say "divider"; consider changing them when a float is active. |
| Lock Split Pane Width | Meaningless for a float. Disabled. |
| Lock Size | Does not affect floats (section 6.2). |
| Lock Layout | Blocks float move, resize and dock (section 6.2). It already blocks closing a pane and splitting, so it also blocks closing a float and creating one. |
| Arrange Split Panes Evenly | Tiled panes only. |
| Resize Window > Decrease and Increase Height and Width | These resize the window, never a float. Today they call sessionInitiatedResize: on the current session (PseudoTerminal.m:12470-12497), which under section 5 would resize an active float. With a float active they must act on a tiled session's grid instead. |
| Cockpit | Lists a tab's sessions through orderedSessions, so floats appear. |
6.9 Other sources of activity
An audit of the other menus, key binding actions, pointer actions, the Python
API and client, it2, AppleScript, built-in functions, Open Quickly,
triggers, escape sequences, session lifecycle, the AI features, the Companion
app and the Toolbelt. Findings are grouped by cause. Anything not listed acts
on its own session and is unaffected.
Splitting a float. Splits enter through two methods in PseudoTerminal.m.
asyncSplitVertically:...(11541) andsplitVertically:before:profile:targetSession:...(11630) are used by the
menus, the context menu and browser context menu, key actions 28 and 29,
the pointer split actions, the API and AppleScript. The only check iscanSplitPaneVertically:, which tests the tab's active session, not the
target. A refusal for a native float target belongs here, after the tmux
branch, and must follow the existing contract (ready(nil, NO)thencompletion(nil, NO)) or a suspended AppleScript command never resumes.splitVertically:before:addingSession:targetSession:performSetup:(11441)
is called directly byiTermSessionLauncher(314, 430) with a session that
already exists, and the launcher then launches a process in it
unconditionally. A refusal here would leave a running session with no tab.
The launcher is reached by the Dock-icon file drop, the split open styles,
and the orchestrator'sstart_sessionwithwindow=current
(OrchestratorDispatcher.swift:3053). These callers cannot be refused; the
target must be changed before the session is created.- Callers with no validation that split the current session: the Toolbelt
Profiles tool (ToolProfiles.m:156, 173), the Profiles window
(iTermProfilesWindowController.m:230, 283), URL splits
(iTermController.m:2110-2121, from PTYTextView.m:8057 and the context
menu), and the browser's cmd-opt-click (PseudoTerminal.m:15012). - Rule (decided): a split whose target is a native float is redirected to the tab's
most recently active tiled pane. Menu items that name the float as the
thing being split are disabled instead.
Swapping. A tiled pane can be swapped with a float, and a float with a
float. swapSession:withSession: (PTYTab.m:6041) exchanges frames and moves
each view into the other's split view. With a split view per float, that
already puts the tiled pane's view in the float's wrapper and the float's
view in the tree. tmux's swap-pane behaves the same way (section 12.1).
- Swap With Next and Previous Pane (key actions 67, 68) pick the partner from
orderedSessions(PTYTab.m:1151), which includes floats. - The context menu's “Swap With Session…” uses split selection mode. For a
swap, floats are valid targets, unlike for a move (section 6.6). - Swap Pane Left, Right, Above, Below (53-56) pick a geometric neighbor.
Floats are not candidates and from a float they do nothing, as for
directional selection. - Each pane takes the other's frame, so both grids change. The pane that
becomes a float gets the float's z-order, title bar and outline, and its
blur underlay is recomputed from its own transparency. The float record is
keyed on the wrapper, so it does not change. - A swap is refused if it would make a workgroup member a float.
- The tab still has a tiled session afterward, so promotion is not involved.
Session-initiated resize. Beyond section 5:
screenSetSize:(PTYSession.m:16071) andsessionInitiatedResize:
(PseudoTerminal.m:7228) both return early in full screen, and DECCOLM has
the same gate (VT100ScreenMutableState+TerminalDelegate.m:1018). A float
can be resized in full screen, so the float path must bypass them.safelySetSessionSize:enforces at least 20 columns
(PseudoTerminal.m:13641), which conflicts with the 2x2 float minimum.sessionInitiatedResize:sets the tab's locked session. The float path
does not need it.- A CSI 8 t parameter of 0 means the screen size, computed from the window
and screen (PTYSession.m:16169). For a float it means the tab's bounds. - CSI 4 t sets a pixel size by calling
setFrameSize:on the window directly
(PTYSession.m:16277). It is not routed throughsessionInitiatedResize:. - CSI 1, 2, 3, 5 and 6 t and SetWindowFrame move, miniaturize, raise or lower
the window. - CSI 14 t and 19 t report the window's size and room to grow. They are
already inexact for split panes; a float's true maximum is the tab. - What tmux does (
input_csi_dispatch_winops, input.c:2122): it ignores
every window operation that changes something (1 to 11, 13, 20, 21, 24),
for every pane, floating or not. Its reports describe the pane alone: 14 t
gives the pane's size in pixels, and 18 t and 19 t both give the pane's
grid. So tmux treats the pane as the window for reporting and offers no
model for resizing. In a tmux tab these sequences never reach iTerm2. - Decided for a native float, following "the pane is the window":
- 8 t and 4 t resize the float. 4 t rounds to whole cells.
- 5 t and 6 t bring the float to the front or send it to the back.
- 14 t, 18 t and 19 t report the float's pixel size, its grid, and the
grid that fits the tab. - 1 t, 2 t, 3 t and SetWindowFrame are ignored. Miniaturizing one float
has no meaning, and their coordinates are screen coordinates.
- The Companion app's resize is gated by
companionSessionCanResizeWindow
(PTYSession.m:16119), which refuses in full screen and for maximized
window styles. Those gates are wrong for a float. - Key actions 49-52 (increase and decrease height and width) pass the
receiving session, and apply modes fan them out to every session in a tab
or window. The redirect to a tiled session (section 6.8) is per receiver.
Focus and raising.
- Focus follows mouse makes a pane first responder on mouse entry. It
changes focus and never restacks floats, which is standard for focus
follows mouse. A click raises. Keyboard actions change focus and do not
raise. - After the floats are hidden no mouse-entered event fires for the pane now
under the pointer, so focus goes to the most recently used session rather
than the pane under the pointer (unverified). focusNewSplitPaneWithFocusFollowsMouse(PseudoTerminal.m:11492) decides
whether a new split takes focus under focus follows mouse. A new float
needs the same rule.- A cmd-opt-shift drag in a pane's text goes straight to
beginDrag:
(PTYMouseHandler.m:212). In a float it starts the live move, like the
title bar. - The title bar's Option requirement (
requireOptionToDragSplitPaneTitleBar)
returns beforebeginDrag:. The live move must start before that gate. - Pinch changes the font of the current session, not the pane under the
pointer (PseudoTerminal.m:1758). That is existing behavior, more visible
with floats.
Counting and ordering.
- AppleScript's
session N of tabis positional insessions
(PTYTab+Scripting.m:26). Tiled sessions come first and floats after, so
existing indexes keep their meaning. session.termidandITERM_SESSION_IDtake the pane number fromsessions.count(PTYTab.m:7494). With the same order, floats do not
renumber tiled panes.- The Toolbelt status tool shows a pane shortcut when the tab has more than
one session and uses the view's ordinal (ToolStatus.swift:997). - “Lock All Panes in Tab” and “Unlock All” iterate
sessions
(PTYTab.m:5648) and include floats, hidden ones too. Tab Color sets every
session insessions. - Apply modes for actions and snippets (PTYSession.m:12094) use
sessions,allSessionsandbroadcastSessions, so they include hidden floats, in
line with the broadcast decision.
API and scripting. See section 11.
Lifecycle.
- Promotion and undo. Closing or burying the last tiled pane saves an
arrangement in which float F is a float; promotion then makes F tiled.
Undo or disinter goes throughrecreateTab:, finds the live layout no
longer matches, and brings the session back as a new tab. Accepted: it is
a narrow case and special handling would risk bugs. - A float whose tab or window is gone is revived as a tiled tab
(iTermController.m:1704-1760). sessionShouldAutoClose:(PTYTab.m:7612) defers close-on-end while a split
divider drag is in progress. A float move or resize drag must count too.- The restart announcement after a session ends, Capture Output's
announcement, RequestAttention's flash and the AI chat mention picker's
highlight are all drawn inside the session view. In a hidden float they are
invisible, and the hidden-floats indicator is the only signal.
Menus outside the main menu.
- The context menu and the title bar menu are the same menu
(menuAtCoord:, iTermTextViewContextMenuHelper.m:382). The float items
from section 6.7 go there. A float always has a title bar, so this is
their main home. - Bury is validated by
textViewCanBury(PTYSession.m:15157), which must
return NO for a tmux float. - “Move Session to Window” on a tmux float calls
breakOutWindowPane:
(unverified what the server does). - The Dock menu, the Profiles menu, Services and the tab-group menus have no
pane items.
Not audited. The browser session's own key binding dispatch, and which
view AppKit picks as the drop destination when text or files are dropped
where a float overlaps a tiled pane.
7. Dragging
Today a title-bar drag, or a cmd-opt-shift drag in the text view, starts an
AppKit drag session (MovePaneController beginDrag:). The view stays put and a
drag image follows the pointer. The outcomes are: onto a pane (split halves,dropInSession:half:), onto a tab bar (new tab, unknownObjectWasDropped:),
or onto nothing (new window, moveSessionToNewWindow:).
Behavior for a float:
| Drag | Behavior | Needs |
|---|---|---|
| Within its tab | Live move, clamped to the tab. | A tracking loop on the title bar. |
| Leaving the tab area | Escalates to the existing drag session. | A handoff from the tracking loop to beginDrag:. |
| Onto a tab bar, between tabs | New tab with the float as its tiled pane. | removeSession: handling a float. |
| Onto a tab in the tab bar | Spring-loaded: that tab is selected after a delay. | New work in PSMTabBarControl; it has no spring-loading. |
| Onto nothing | New window sized from the float's grid. | removeSession: handling a float. |
| Onto another tab's or window's content | Stays a float at the drop point, clamped. The grid shrinks only if the destination is smaller. | A new tab-level drop target. |
| Control held over a pane | Split halves appear and the float docks there. | Reads live modifier state; nothing reads it today. |
Rules and complications:
- Holding Control during a live move escalates to the drag session at once, so
the float can be docked into its own tab. - Floats are never split targets.
SessionViewregisters every pane as a drop
target (SessionView.m:210), so floats opt out. A drop that lands on a float
counts as a drop on its tab. - Other floats can cover the pane you want to dock into. Fade or hide them
while Control is held. - Session-count checks must count tiled panes only (section 3.3).
- Dragging out the last tiled pane, to another tab, a tab bar or nothing,
promotes the front float in the source tab. The tab-bar drop path
special-casestab.sessions.count == 1(PseudoTerminal.m:8757) and must
count tiled sessions. In a tmux tab nothing is promoted (section 12.5). - A tab dragged onto a pane merges as a split and is refused when the tab has
more than one session (PTYTab.m:7410). A tab with floats must also be
refused. - A tab moved between windows carries its floats. Clamp them after the
destination window refits. - Entering a maximized tab mid-drag unmaximizes it (
sessionDraggingEntered:),
which runssetRoot:. requireOptionToDragSplitPaneTitleBarapplies to the escalated drag, not to
the live move.- Layout lock and session lock gate the live move as they gate
beginDrag:. - Every drag has a menu equivalent (sections 6.7 and 6.8), for keyboard users
and the API.
Order of work: live move plus drag-out to a tab bar, a new window and a split
reuse existing code and come first. The floating drop into another tab or
window and spring-loaded tabs follow.
8. Mouse and hit testing
- Click-to-activate works with default AppKit hit testing if the front float
is the topmost subview. - Each
SessionViewinstalls tracking areas over its bounds withNSTrackingActiveAlways(SessionView.m:1537-1562). They are not
occlusion-aware (unverified AppKit behavior). The expected failures: a tiled
pane under a float also getsmouseEntered, so focus-follows-mouse can steal
focus; moving from a float back onto the pane it sits inside produces nomouseEntered; andmouseMovedreaches the covered pane, driving URL
underlines, cursor changes and mouse-motion reporting. The fix is a
window-levelhitTestgate like the one at SessionView.m:1609. - NSSplitView divider cursor rects under a float may still change the cursor
(unverified). - Mouse-report coordinates, scroll wheel routing and selection autoscroll are
view-local and follow hit testing.
9. Existing features
| Feature | Required behavior or problem |
|---|---|
Global Search, Open Quickly, notification click, AI chat, Cockpit, FocusBuiltInFunction, git status component |
All call reveal. It must unhide and raise (section 6.3). Global Search also moves its panel away from the match rect and assumes the rect is visible. |
| Notification click-to-reveal | sessionWithViewId: (PTYTab.m:1073) walks root_ only and misses floats. |
| Status bar | With per-pane status bars, a float has its own. With the shared window bar, the bar belongs to currentSession (PseudoTerminal.m:13010) and swaps when a float takes focus, taking any open search or filter component with it. If the float and the tiled pane differ on showing a bar, the tab height changes. |
| Workgroups | No entry path checks the session's kind. Entry is refused for floats in enterDisposition (iTermWorkgroupController.swift:179). Peer swap and split children are not needed. A swap that would make a workgroup member a float is refused (section 6.9). |
| Instant Replay, Filter, zoom on selection, screenshot mode | replaceActiveSessionWithSyntheticSession: (PTYTab.m:1772) works inside a float's split view. Replay saves and restores the window frame; a float needs the same for its own frame. Refuse dock and resize during replay. |
| Broadcast input | Domains use currentTab.sessions or allSessions, so floats are included, hidden ones too. The background stripes composite like background images and need no work. |
| Dimming | setDimmingForSession: (PseudoTerminal.m:12529) dims every non-active session. Floats are treated like any pane: with “dim inactive split panes” on, an inactive float is dimmed, and focusing a float dims the tab's other panes. The float's outline color shows focus either way. |
| Accessibility | AppKit derives the accessibility tree from the view hierarchy, so floats appear without work. VoiceOver reads tiled panes first and floats after, and still announces covered panes. Check the traversal order with VoiceOver on. |
| Tab title, color, icon, activity | They follow the active session, so they follow an active float. Acceptable natively. |
| Bury | Works per session natively. Disinter goes through recreateTab: (section 10.4). |
| Undo close | Section 10.4. |
| Tab drag image | image: (PTYTab.m:2893) walks root_ and omits floats. |
| Size readout during window resize | Shows the current session's grid. With a float active it will not change. |
| Window resize snapping | windowWillResize:toSize: (PseudoTerminal.m:5868) uses the active session's cell size and chrome. It should use a tiled session. |
| Small-float overlays | The find bar (minimum 150 points), announcements, the minimal composer (minimum 217 points) and the hover URL view assume a reasonably large pane. They behave as in a narrow split today. |
| Child and separate windows | The find indicator, autocomplete and history popups, and completions are windows placed at the cursor. They ignore in-tab z-order and can appear for a cursor that is covered. |
| Per-session toolbar | Not needed for floats while workgroups are refused. |
| Session variables | Add session.isFloating. session.termid pane numbers come from sessions; with tiled sessions ordered first, floats do not renumber tiled panes. |
| Triggers | None create or move panes. Show Alert, Open Password Manager and Post Notification end in reveal or in the sessionWithViewId: lookup above. The password manager types into the window's current session, so reveal must make a float the active session, not only raise it. |
| Unaffected | Secure Keyboard Entry, Composer, Restart Session, tab groups, swipe between tabs, SSH integration, hotkey windows, Services, and Toolbelt tools other than Profiles and Status (section 6.9). The Companion app lists floats as ordinary panes; its resize gate is in section 6.9. |
Not audited in depth: portholes, annotations, multi-cursor, copy mode, the
badge and the cursor guide.
10. Persistence and restoration
10.1 Schema
Floats are an additive sibling of the tree, never a new node type inside it.
Old builds ignore an unknown top-level key.
"Root": { ... unchanged; at least one session in a native tab ... }
"Floating Panes Hidden": bool
"Floating Panes": [ // back to front
{ "frame": {x, y, width, height}, // points, relative to the tab container
"Container Size": {width, height},
"Desired Grid Size": {columns, rows}, // only if shrunk to fit the tab
"Saved Frame": {x, y, width, height}, // only while the float is maximized
"Node": { ... same shape as a node in the tiled tree ... } } ]- The active session is not recorded on the float. Session nodes already
carryIs Active(PTYTab.m:3242), andNodecontains one. A second flag
would be a second source of truth. Desired Grid Sizebacks the shrink-and-remember rule in section 5.Nodeholds a session node today. Using the tree's node shape leaves room
for splits inside a float without a schema change.- On restore, scale the origin by the ratio of the new container to the saved
one, take the size from the session's saved columns and rows at the current
font, then clamp. Split frames are saved in points and act as proportions
because the split view rescales them; floats have no such scaling. - Omit the key when
PTYTabArrangementOptionsOnlySessionIDis set (Duplicate
Session and the archive path).
10.2 Walkers
These PTYTab class methods take only arrangement[Root] and need a floats
pass. One enumeration helper should replace the pattern:
registerSessionsInArrangement(330)arrangement:passesTest:(3704)modifiedArrangement(3709)- the four
repairedArrangementvariants (3718-3756) arrangementForSessionWithGUID(3758)sessionMapWithArrangement(4123)drawArrangementPreview(3494)openPartialAttachmentsForArrangement(3624)
openPartialAttachmentsForArrangement matters most. Unextended, a float's
process is not reattached; the float relaunches fresh and its old process is
adopted as a tab by iTermOrphanServerAdopter.
10.3 Trust gate
RiskAnalyzer in iTermArrangementTrustGate.swift (96-99, 177-190) walks
Tabs, Root, Subviews, Session. A quarantined arrangement could hide commands,
initial text and triggers in float sessions and be reported as zero risk.
Extend it in the same commit as the decoder.
10.4 Undo close and bury
Closing a pane stores the whole tab's arrangement (PTYTab.m:922-931).recreateTab: builds a GUID map from the tree, requires every live session in
the tab to be in it, and otherwise re-adds the revived session as a new tab. On
success it calls replaceWithContentsOfTab: (PTYTab.m:3515), which moves onlyroot_ subviews.
recreateTab:should rebuild and check only the tiled tree and leave live
floats alone.- Undo-closing a float re-adds it from its saved frame with no rebuild.
- Bury and disinter use the same path (iTermBuriedSessions.m:66-75, 144).
10.5 Other paths
- Maximize.
maximizesnapshots the tab andunmaximizerebuilds from the
snapshot. Floats stay out ofidMap_andsavedArrangement_and are encoded
live in both branches of the encoder (PTYTab.m:3969-3997). - Duplicate Tab.
arrangementForDuplicationthentabWithArrangement:.
It works once the encoder and decoder handle floats. - Graph encoder. Key floats by session GUID in an
encodeArrayWithKey:
array. Duplicate identifiers trip an assert (iTermGraphEncoder.m:221-223).
Order gives z-order. - Saved window arrangements and hotkey-window arrangements use the same
encoder, so they include floats. - Older builds are not a constraint. One reading a newer arrangement
loses the floats, and may fail to restore a window that contains a
floats-only tmux tab. - Settings sanitizer. Redaction is by key name at any depth, so session
dictionaries under the new key are covered. Avoid new sub-keys namedvalue,text,name,title,commandorurl.
11. API and scripting
- Proto. Add
repeated FloatingPane floating_panes = 12onTab, with aSplitTreeNode(or session summary), frame and z-index. Never add aoneof
case toSplitTreeNode: the Python client'sSplitter.from_nodetreats any
non-session link as a splitter (session.py:42-58). - Do not reuse
minimized_sessions. The client marks those sessions buried
with no grid size. This happens by itself unless prevented:minimizedSessions(PTYTab.m:7490) issessionsminus the active session,
so oncesessionsincludes floats, every float in a maximized tab is
reported as minimized while it is still visible. It must usetiledSessions. - Consistency. Today the session list comes from
rootSplitTreeNodewhile
subscriptions, variable scopes, activate, close and split look sessions up
throughallSessions. Floats would produce notifications for sessions the
client's tree has never seen. Old clients trigger an extra refresh and
otherwise ignore them. - Notifications. A float resize that changes the grid already fires
LayoutChanged, throughPTYSessionDidResizeNotification
(iTermAPIHelper.m:950). Only window live resize and split divider drags are
suppressed, so a float edge-drag would send a full ListSessions payload per
grid step; it needs the same suppression. Float move, restack, hide and
show, and a dock that keeps the grid fire nothing. Add a notification or
reuse the layout-changed one. - Requests. New: create float, dock, set frame, raise. A split of a native
float is refused.SetTabLayoutchecks tree shape only and is unaffected in
native tabs. - Python client. Add
Tab.floating_sessions; include floats inall_sessionsandget_session_by_id; keepTab.sessionstiled-only.Tab.current_session(tab.py:189) searchessessionsand returnsNone
when the active session is a float, in any tab; it must search all
sessions.Tab.sessionscan be empty for a floats-only tmux tab. apply_layoutmust see tiled sessions only, or existing scripts fail
withorphanedSessionon any tab that has a float.- AppleScript.
sessions of tabis[self sessions]and includes floats
automatically, after the tiled sessions. There is no command to create,
dock, move or raise a float; add them or leave AppleScript without. - Built-in functions.
select_pane_in_directionexcludes floats.move_session_to_new_tabandmove_session_to_new_windowwork for a float
throughMovePaneController.
Further items from the audit of every endpoint:
active_session_idand the Focus response name a float that is not inroot.SplitPanewithsession == "all"iteratesallSessions. One refused
float setsCannotSplitfor the whole response although the tiled splits
succeeded (iTermAPIHelper.m:2675).Activatewithselect_sessionon a maximized tab does unmaximize, set
active, maximize (iTermAPIHelper.m:3130). With a float target the last
step would maximize the float.GetBroadcastDomainslists float IDs that a client cannot resolve from the
tree.- In the Python client,
Tab.all_sessions(tab.py:165) is whatget_window_and_tab_for_session,Session.tab,Session.windowand the
layout-change merge go through. Floats must be in it, orSession.tabisNonefor a float.Tab.update_sessionandTab.update_frommust carry
floats too. Session.__init__(session.py:250) has two modes, a tree link with frame
and grid or a summary marked buried. Floats need a third, with a frame and
grid.- An old client that gets a focus change onto a float does one extra
ListSessions and Focus round trip (app.py:423). it2resolves a missing session argument to the first leaf of the
selected tab's tree, not toactive_session_id(APIClient.swift:53). With
a float focused every such command targets a tiled pane, and on a
floats-only tab it fails. It should use the active session.broadcast addrejects a float ID because it validates against tree leaves
(AppCommand.swift:404).move_session(MoveSessionBuiltInFunction.swift:45) accepts a float as
the destination, which would split a float and must be refused, and as the
source, which is a dock and is allowed.apply_layoutresolves a float
GUID as a leaf reference; it should be an error.
12. tmux integration
12.1 Protocol facts (verified in tmux source)
- Opt in with
refresh-client -f new-layouts, control clients only. Layouts
then arrive as JSON:{"V":2,"L":cell}. There are no spaces and no checksum. - A float is a leaf with a
"z"key, mixed in with tiled siblings. The root
can be a floating leaf, and nodes can contain only floats. - Layout x, y, w and h are the inner content rectangle.
move-pane -X/-Y,resize-pane -x/-yandnew-panetake outer coordinates when the float has
a border. Sending layout values back unchanged drifts by one or two cells. - Layout
wincludes reserved scrollbar columns.pane_widthis the content
width. pane_zis non-empty for tiled panes, so it cannot identify a float.pane_floating_flagcannot either while the window is zoomed: it tests the
pane's layout cell (window_pane_is_floating, window.c:2969), and a float
hidden by zoom has no cell, so it reports 0. Use the"z"key in the
unzoomedwindow_layout, which still lists hidden floats.- While a modal pane exists, tmux adds
Oto the window flags sent with%layout-change(window.c:1395, undocumented).#{window_modal_pane}gives
the pane ID. - After
break-pane -Won every tiled child of a node, tmux appears to leave
that node at 0x0, so the JSON would contain"w":0, which tmux's own parser
rejects (unverified by running). - These changes send no
%layout-change: raising a float by focusing it (only%window-pane-changed), mouse move and resize by another client, androtate-window. Arefresh-client -Bsubscription on#{window_layout},
which always uses the JSON format, is required to stay in sync. display-popupand the buffer and customize editors are modal floating
panes with pane IDs and%output. A control client's owndisplay-popupis
a silent no-op, but one opened by another client is visible.- While a pane is modal,
select-paneto another pane silently does nothing,
and swap, join and break of the modal pane fail with "pane is modal".send-keysto other panes is not blocked (unverified by running). split-windowon a float creates another float.swap-paneexchanges
cells, so a tiled pane can become floating.break-pane -Wmakes a tiled pane floating.join-panewith a floating
source and no destination, or the source as its own destination, tiles it
(cmd_join_pane_tile, cmd-join-pane.c:386). Both fail while the window is
zoomed, and tiling fails with "no space for a new pane" when the layout is
full.- Zoom (
window_zoom, window.c:986) hides every float except those created
with-Aor as a modal with-O, which carryPANE_FLOATOVERZOOM. Those
keep their geometry, and a move or resize made while zoomed is copied back
on unzoom. A hidden float loses its layout cell until unzoom. - A float can itself be zoomed. It fills the window and moves behind the
floats in z-order, so over-zoom floats still show above it. It returns to
the floating part of the z-order on unzoom. - Zooming does not take the active pane away from an over-zoom float. A modal
or-Afloat can be the active pane over a zoomed pane. - Creating an
-Aor-Ofloat in a zoomed window restores the zoom
afterward (restore_zoom, cmd-split-window.c:146). Other commands that
need the full layout unzoom first throughwindow_push_zoom. - On window resize tmux shrinks oversize floats and fixes right and bottom
overflow only. Negative offsets persist. Clamping is per window, so a smaller
client moves floats for everyone. - A v1 layout string is still accepted by
select-layoutafter opting in and
leaves floats alone. KeepiTermTmuxLayoutBuilderon v1. A JSON layout would
need quoting and would also apply active-pane and z-order state. - Default bindings create floats:
*runsnew-pane, and Tab and BTab open
process-less floats running a mode. - Border lines, border styles, window style and popup title are per-pane
options readable throughiTermTmuxOptionMonitor. - Test fixtures:
regress/layout-custom.shhas 55 JSON layouts, including
nested float-only nodes, reordered keys and rejects.
12.2 Fixes independent of this feature
- Layout parse failure aborts the connection. A client that has not opted
in gets0000,for a window with only floats.parsedLayoutFromString:
returns nil and TmuxWindowOpener.m (107, 159, 568) callsabortWithErrorMessage:. This affects shipping builds against tmux 3.8. - Commands sent without error tolerance abort the connection
(TmuxGateway.m:636-643). With floats and modals these can now fail:swap-pane(TmuxController.m:3311),break-pane(2731),select-layout
(3354, "no panes" on a floats-only window),resize-pane -L/R/U/D(2323),new-window(2520) and thelist-panesthat starts a split (2371).
12.3 Opting in
Feature detection is better than a version gate. tmux 3.2 and later silently
skips unknown -f flags, and older versions fail on a command iTerm2 already
sends tolerantly. Confirm with #{client_flags} or a layout beginning with{. The version parser accepts next-3.9, 3.8, 3.8a and 3.8-rc3 as at
least 3.8, the last by accident, and never accepts OpenBSD version strings.
Versions are also compared as decimal numbers
(versionAtLeastDecimalNumberWithString:), so a future tmux 3.10 would
compare as 3.1 and fail a 3.8 gate.
Opting in cannot ship before the UI exists, because floats would then appear
in layouts that nothing can display.
12.4 Mapping onto the model
- Parser. A new JSON parser emits the existing dictionary shape for tiled
cells plus a separate float list, collapsing nodes left with one child.depthFirstSearchParseTree:callers inopenWindows:anddecorateParseTree:must see floats too, or floats get no history, state,
user variables or tab-color decoration. - Zero-size cells. The parser must accept a node with a zero width or
height rather than abort the connection (sections 12.1 and 12.2). - Dropped output.
didLearnLayout:forWindow:(TmuxController.m:959)
feedsTmuxDroppedOutputScanner.didLearnPanesfromwindowPanesInParseTree:. It must include float pane IDs, or a float's%outputcounts as dropped even after opting in. - Conversion.
replaceViewHierarchyWithParseTree:terminates sessions
missing from the new tiled tree. A pane toggled between tiled and floating by
the server must be moved, not terminated and recreated. - Cell-to-pixel mapping. Each tiled pane adds margins, a scroller and
optional title bar, and a divider is one point where tmux uses a cell
(_sessionSizeWithCellSize:, PTYTab.m:2553). The pixel x of tmux column X
drifts with the number of dividers to its left. So there is no global
mapping. The rule is local:- Find the tiled pane that contains the float's top-left content cell in
tmux coordinates. Place the float's top-left cell exactly over that cell
of that pane. - The float moves in pixels when the tiled layout changes, because the pane
it is anchored to moved. That is the correct result. - If the cell is not inside a tiled pane (a divider cell, a negative
offset, or past the last pane), anchor to the nearest tiled pane and
offset by whole cells. - In a floats-only window there is no tiled pane. Use a uniform grid from
the container's origin.
- Find the tiled pane that contains the float's top-left content cell in
- Chrome. The content rectangle is fixed by the server, so chrome cannot
take space from it, and it cannot always go outside it either: a float can
be as large as the pane or the window it covers. tmux has the same problem
and solves it with the border. The layout gives a float's content
rectangle, say column 10, row 5, 40 wide and 12 tall. If the float has a
border, tmux draws it in the cells just outside that: the float occupies
columns 9 to 50 and rows 4 to 17 on screen. That larger rectangle, the
content plus a one-cell border on every side, is what the move and resize
commands address (section 12.1). iTerm2 puts its chrome in that border.- Bordered float. The wrapper covers the content plus the border. The
title bar goes in the border's top row, and the outline, resize band and
session margins go in the sides and bottom. Content cells stay aligned,
and a float as large as the window has its border inside the window. - Title bar height. The normal
SessionTitleViewis used, at its
normal height, which is the status bar height plus one point
(SessionView.m:53). That is taller than a cell at common font sizes. The
excess extends upward, away from the content, and is clipped at the top
of the tab. A title bar never covers its own float's content; being
clipped is preferred. No compact variant is built. - Borderless float (
pane-border-linesis none). tmux reserves no
border and draws no chrome. iTerm2 draws a one-point outline over the
edge of the content and hides the title bar. A toggle shows and hides
it, following the stoplight hotbox, which slides in and out behind a
narrow toggle button when it would cover a toolbar or status bar
(_stoplightHotboxToggle, iTermRootTerminalView.m:175). While shown,
the title bar sits above the content, outside the float, and covers
whatever is there. It is clipped at the top of the tab, so for a
borderless float in the top row it cannot be shown. - Negative offsets. tmux leaves floats partly outside the window, so
iTerm2 must too. The wrapper is clipped at the tab's edge, and a float
whose title bar is outside is reachable by keyboard and menu until it is
moved. - The session's side margins plus the outline must fit in one cell width.
The default margin does. A larger margin setting makes the chrome extend
past the border and cover a little more than tmux would.
- Bordered float. The wrapper covers the content plus the border. The
- Border status. Floats reserve no row for
pane-border-status.adjustLeavesInNode:(TmuxLayoutParser.m:148) would wrongly shrink a float
at y = 0. - Client size.
variableTmuxSizefilterssessionViewsby geometry and
would count floats as stacked tiled panes. Floats must not contribute. - Raise.
%window-pane-changedto a float must bring that float to the
front, since the server raises a float when it becomes active. - Move and resize. Send
move-paneandresize-panewith the outer
coordinate conversion._isDraggingSplitInTmuxTabforces legacy rendering
during divider drags; a float drag needs an equivalent. - Split from a float. tmux creates another float. iTerm2's split
bookkeeping (willSplitTmuxPane, the senior and junior logic) assumes a tree
change, and a popup appearing first would steal focus meant for the new pane. - Zoom.
maximizeassumes the zoomed pane is the active session. With a
modal or-Afloat active it would maximize the float. - Floats under zoom. Native tabs keep every float visible over a
maximized pane. In a tmux tab the server decides: while zoomed, show only
the floats in the visible layout, which are the over-zoom ones. The others
are hidden by the server, not by the hide toggle, and return on unzoom.
They still exist, so they must not be terminated when they drop out of the
visible layout (see Conversion above), and they are identified from the
unzoomedwindow_layout(section 12.1). - Title bars. A float appearing in a single-pane tab must not turn on the
tiled pane's title bar and resize the client. The rule in section 4.3 counts
tiled panes only. - Dragging. Within-tab moves map to
move-pane -X -Y. Dragging out maps tobreak-pane, which makes the pane tiled in a new window. - Dock. “Dock Floating Pane” maps to
join-paneon the float, which
tiles it (section 12.1). The server chooses where it goes. It is refused
while the window is zoomed. - Hazard.
setTmuxLayout:paths runsetRoot:(section 3.1).
12.5 Floats-only windows
Killing the last tiled pane keeps the window, and tmux then shows only
floats. iTerm2 supports this: a tmux tab may have an empty tiled tree. Native
tabs never do, because promotion (section 1) keeps one tiled session.
iTerm2 does not promote in a tmux tab. It could, by sending join-pane to
tile the front float, but tmux itself leaves the window floats-only, another
client attached to the session would see a pane change state for no reason
it can observe, and the command fails while zoomed. The same holds when the
last tiled pane is moved out with break-pane.
The container refactor (section 3.5) makes this representable: root_ is a
child of the container and can be a split view with no subviews, with the
floats beside it. The work, roughly in order of difficulty:
- Client size.
tmuxSizeForHeight:(PTYTab.m:4589) measures the tiled
tree and returns 0 when it is empty. In fixed-size modefitLayoutToWindows
(TmuxController.m:1094) then returns early, which freezes resizing for every
window in the session. An empty tree must report a size derived from the
container, as the code at PTYTab.m:7559 already does from the tab's frame.variableTmuxSizeneeds the same treatment. - Tab size.
sizeandcurrentSizecome fromroot_, which is empty,
and floats do not contribute. The tab must keep the size the server gave
for the window, or the window refit collapses it. - Transitions.
replaceViewHierarchyWithParseTree:and thesetTmuxLayout:paths must handle a tree becoming empty (last tiled pane
killed) and becoming non-empty again (a float is tiled). Maximize androot_.subviews[0]uses must tolerate an empty root. - Callers of
tiledSessions. Any that take the first element or assume a
count of at least one need a fallback, for example window resize snapping
(section 9). The rename in section 3.3 forces a look at each; "may be empty
in a tmux tab" is one of the things to check. - Parsing. A window with only floats has a root that is a floating leaf
or a node of floats. Without opting in it is0000,(section 12.2). - Close. Closing the last pane of such a window sends
%window-close
with no preceding%layout-change. - Background. The empty tiled area shows the container's background.
A floats-only tab still has sessions, because floats are sessions. sessions
and activeSession stay non-empty, so code that assumes a tab has a session
is unaffected. What becomes empty is the tiled tree, which narrows the risk to
two groups:
- Code that indexes into
root_. In PTYTab.m there are eleven such sites.
Most are guarded already: the maximize paths (1134, 1198, 5784) run only
while maximized, the checks at 2070 and 2089 test for a count of one first,
and 2276 handles an empty split view becausereplaceViewHierarchyWithParseTree:already empties the tree transiently.
The ones to fix arereplaceWithContentsOfTab:(3544, 3548) and the split
assert at 2492. - Callers moved to
tiledSessionsduring the refactor. Each can now see an
empty list in a tmux tab.
The tree is already empty for a moment today, between the last session being
removed and the tab closing, so the recursive walkers tolerate it. What is new
is staying empty. A test that builds a tmux tab with an empty tree and floats,
then calls each public PTYTab method, should be part of this work.
Code outside PTYTab.m that reads the tree. An audit of the callers of
PTYTab's tree, size and layout methods, and of the tmux parse-tree consumers.
Each item is something an empty tiled tree breaks or that must learn about
floats.
Sizes, where a zero from one tab spreads:
tmuxCompatibleSize(PseudoTerminal.m:3978) takes the minimum of every
tmux tab'stmuxSize. One floats-only tab reporting 0 makes the whole
window's size 0. It feedssetClientSize:(PseudoTerminal.m:5266,
TmuxController.m:1036).tmuxSizeis also read at TmuxController.m:1058, 1069, 1090 and 1113 and
sent to the server at PseudoTerminal.m:7669. The guard at 1094 hides the
zero by doing nothing, which is the freeze described above.fitWindowToTab:(PseudoTerminal.m:10638) fits the window to[tab size].
The window sizing helper readscurrentSizeandminSize
(PseudoTerminal.m:11803, 11812), as dominWidth(12237) andwindowWillResize:toSize:(5953). Those take a maximum, so a zero is
harmless there, but they must see the float contribution tominSize.
Tab construction and layout:
openTabWithArrangement:(PseudoTerminal.m:12259) returns nil whensessionViewsis empty. It stays correct only ifsessionViewsincludes
floats.parsedLayoutFromString:(TmuxLayoutParser.m:96-120) already returns nil
for the empty v1 layout, with a comment about tmux 3.8, and wraps a lone
leaf in a root splitter. The JSON parser must produce a root splitter with
no children for a floats-only window.TmuxWindowOpenerbuilds its per-pane requests withdepthFirstSearchParseTree:(TmuxWindowOpener.m:112, 663) and its pane
list withwindowPanesInParseTree:(170, 607). Both must cover floats, or
floats get no history and no state.positionForWindowWithPanes:windowID:(TmuxController.m:2915) restores a
window's position by looking up its pane IDs. A floats-only window has no
tiled pane IDs, so floats must be in the list.didLearnLayout:forWindow:(TmuxController.m:959), already listed in
section 12.4.
API and scripting:
rootSplitTreeNode(iTermAPIHelper.m:2403) sends a root with no links for
a floats-only tab. The Python client builds an emptySplitter, soTab.sessionsis an empty list and scripts that index it raise.apply_layoutalready refuses tmux tabs (iTermLayoutResolver.swift:124),
so the empty tree never reaches it.- On a floats-only tab an empty
SetTabLayoutrequest passes the tree-shape
check and sends a size and a layout built from an empty node
(PTYTab.m:5104). What the layout builder produces for no children is
unverified.
Not a problem, checked:
tab.sessions.firstObject.profile(TmuxController.m:3889) and[[tab sessions] objectAtIndex:0](MovePaneController.m:204,
PseudoTerminal.m:4326) readsessions, which includes floats.- cmd-opt-N (iTermApplication.m:465), the Cockpit and the AI chat mention
picker readorderedSessions, which includes floats. arrangeSplitPanesEvenlyand the split tree rebuilder act on the tiled
tree and do nothing useful, but nothing harmful, when it is empty.
None of this is architectural once the container exists. The client-size and
tab-size items are the risky ones, because a mistake there affects every
window in the tmux session, not just the floats-only one.
12.6 Popups, modals and process-less panes
Decision: no popup-specific work. tmux removed popups and their overlay
machinery (commit 34cd5da4); display-popup remains as an undocumented
compatibility command that opens a modal floating pane. A modal float is
treated as an ordinary float, and the error tolerance from section 12.2 keeps
its failures from aborting the connection. The items below record what that
leaves imperfect. They are not planned work.
- Races.
display-popup -E trueopens and closes within milliseconds. The
opener's queued commands fail, and iTerm2 reports "A tmux pane terminated
immediately after creation" and drops the window's affinity
(TmuxController.m:3947). - Tab identity.
setActiveSession:updates the tab and window title, thetab.currentSessionvariable and focus history, and closes Instant Replay on
the previous pane. A popup becoming active makes the tab title flicker unlessset-titlesis on. - Profile side effects. Popups use the shared tmux profile, so triggers,
automatic profile switching, and bell and idle alerts apply. A font change
from profile switching re-fonts every pane and refits the window. - Prompts. A popup counts toward "confirm closing multiple sessions" and
prompts on close under "prompt if jobs". - Modal detection. iTerm2 learns that a window has a modal pane from the
Owindow flag in%layout-change, and which pane from#{window_modal_pane}(section 12.1).parseLayoutChangeCommand:
(TmuxGateway.m) reads onlyZfrom the flags today. - Modal enforcement. All input goes by pane ID and bypasses tmux's modal.
iTerm2 must enforce it, or snap focus back.setCurrentWindow
(TmuxController.m:4039) increments an ignore counter beforeselect-window;
if tmux refuses during a modal, the next real notification may be swallowed
(unverified). - Dismissal. A popup without
-Eleaves a dead pane waiting for a key.
That dismissal is in tmux's client-key path, whichsend-keysbypasses.
iTerm2 gets no notification that a pane died. Close withkill-paneordisplay-popup -C. - Process-less panes.
new-pane -Epanes never send%output, and modes
draw to a mode screen thatcapture-panereads only with-M. They would
render blank while keys still drive the mode. iTerm2 ignores%pane-mode-changedtoday, so tiled panes in copy mode already have this
problem. - Monitors. Each tmux session installs title, job and status-line
monitors. Each popup adds pollers against a pane that may already be gone. - Bury.
buryon a tmux pane hides the whole tmux window. Disable it for
floats.
12.7 Multiple clients
- A second control client that has not opted in gets v1 layouts without
floats. It still receives%outputfor float pane IDs, which iTerm2 treats
as dropped output. - Layout format is per client; z-order and the active pane are per window.
13. Phased plan
Small, reviewable commits, each with tests. The steps order the work. The
feature is released as a whole, so no step has to be shippable on its own
except step 0.
- tmux fixes from section 12.2. Independent; ship first.
- Metal spike (throwaway). Confirms the in-window container. Includes
the blur underlay check from section 4.1.
1a. Test infrastructure. Built before the feature so every later step can
be checked without a person at the keyboard (section 14.1). - Container refactor. First, the tab container view for every tab
(section 3.5), as its own commit with no floats. Then the float wrapper
with its split view,setRoot:andcleanupAfterRemove:changes,sessionsversustiledSessionswith the temporary rename, and the
call-site classification. While classifying, mark eachtiledSessions
caller that cannot handle an empty list; those are fixed in step 10. Add
tests for remove, maximize, enumeration andreplaceViewHierarchyWithParseTree:; none exist today. - Native float basics. The menu items, sizing rules, title bar, outline,
shadow, blur underlay, live move, edge resize, keyboard move and resize,
raise on click, close, hide and show. - Focus and mouse. Occlusion-aware hit testing for focus-follows-mouse and
hover, navigation order, numbering, maximize. - Persistence. Encoder, decoder, walkers, trust gate, partial attachment,
undo close and bury, Duplicate Tab, with round-trip tests. - Dragging. Drag-out to tab bar, window and split with Control; then the
floating drop target and spring-loaded tabs. - Existing features. Reveal, status bar, workgroup refusal, synthetic
swaps, the rest of section 9, and from section 6.9 the split redirect,
the swap rule, the resize gates and the focus rules. - Automation. Proto field, handlers, Python client,
it2, AppleScript,
built-in functions, and the consistency items in section 6.9. - Remaining creation points from section 6.1.
- tmux. In this order:
- The JSON parser, with floats reaching every parse-tree consumer
(section 12.5 audit). - Opt-in and the server-owned geometry driver, with local cell
alignment. - Floats under zoom.
- Floats-only windows: tab and client size for an empty tree, then
transitions, then thetiledSessionscallers marked in step 2. The size work comes first
because a zero from one tab reaches every window in the session.
- The JSON parser, with floats reaching every parse-tree consumer
Release notes go in docs/notes-3.7.txt per docs/release-notes-guidelines.md.
14. Verification
14.1 Test infrastructure
The feature is mostly about what the user sees and does with the mouse, and
neither unit tests nor the Python API cover that. These come first:
- Window capture. A debug-only way to capture the real, composited window
to a PNG, Metal layers included, for example with ScreenCaptureKit. The
Python API's screenshot renders one session's grid, not the window, and
ordinary view snapshots omit Metal layers. The dev build needs Screen
Recording permission once. - Event harness. A helper in the test bundle that hosts a real terminal
window and delivers synthesized mouse events to its views: down, dragged,
up, moved, with modifiers. It covers live move, edge resize, click to
raise, the title bar's double-click and the handoff to a pane drag. - Driving the dev instance. The Python API can invoke menu items, send
text and read layout and focus against the-suiteinstance. API
connections from outside the app prompt for authorization, which must be
pre-approved for the suite. - GUI automation of the dev instance. For this project the author has
permitted controlling the computer and using accessibility APIs, which the
repository's general rules forbid. Real clicks and drags against the-suiteinstance cover what the in-process harness cannot: AppKit drag
sessions between tabs and windows, and how the window server delivers
pointer events to covered panes. Synthetic input must only ever go to the
dev instance, never to the main iTerm2 where the tools run. - tmux. tmux built from
~/git/tmux, a plain second client run in a pty,
and recorded control-mode transcripts fed to the parser and controller in
unit tests.
Two constraints on the implementation follow from this:
- Float move and resize are written as mouse event handlers with explicit
state, not as a modaltrackEventsMatchingMask:loop, which a test cannot
drive. This supersedes the "tracking loop" wording in section 7 and the
appendix. - Geometry lives in plain types with no views: clamping, cell snapping, edge
anchoring, shrink-and-remember, minimum size, tmux cell alignment and
promotion. They are unit-tested exhaustively.
What still needs a person: a final judgment on the Metal spike, since
flicker and lag in motion are hard to judge from captures; VoiceOver; and how
the feature feels.
14.2 Tests
- Unit tests. A tmux tab with an empty tiled tree and two floats, with
every public PTYTab method called on it;tmuxSize,sizeandtmuxCompatibleSizefor a window that mixes a floats-only tab with a
normal one; the tree going empty and non-empty throughreplaceViewHierarchyWithParseTree:. PTYTab container operations (step
2); arrangement round trips including old-format input; the trust gate with float sessions; the
JSON layout parser against fixtures fromregress/layout-custom.sh(tiled
only matches v1, mixed, floats only, one tiled child plus floats, nested
float-only nodes, negative offsets, malformed input). - Manual, native. Create, move, resize, hide, dock and close floats;
relaunch and upgrade with floats running jobs; Duplicate Tab; window resize,
full screen and font changes; every drag in section 7; maximize; Global
Search into a covered or hidden float. - Manual, tmux. Build tmux from
~/git/tmuxand runtmux -CCwith a
second plain client:new-panevariants,display-popup,break-pane -W
andjoin-pane, zoom with and without-A, modal-O, killing the last
tiled pane, detach and reattach, and window resize. Add the recipe to
tests/tmux_layout_smoke.md. - Regression. tmux 3.7 and older must still get v1 layouts.
Appendix: reuse candidates, checked against the code
| Need | Candidate | What it actually offers |
|---|---|---|
| Move loop | Minimal composer (iTermMinimalComposerViewController.m:77-112) | A real trackEventsMatchingMask: loop, but vertical only and clamped to the superview. A pattern, not reusable code. |
| Edge resize | Split divider drag (PTYSplitView.m:144) | It defers to NSSplitView, which owns the tracking loop and cursor rects. Only the cell quantization idea (PTYTab.m:6967) carries over. |
| Grab handle | SessionTitleView.m:377 | mouseDragged: starts the pasteboard drag immediately. It needs a live-move mode. |
| Dock and drag-out | MovePaneController | Suitable. It has no notion of geometry, so "float here" needs a new drop target. |
| Title bar | SessionTitleView | Close, menu and lock buttons, pane number, dimming, double-click and per-pane status bar hosting. Reused as is, apart from the drag and the menu. |
| Border | iTermActivePaneBorderView | Active session only, inside the scroll view. Not a drop-in. The wrapper draws the float's outline instead. |
| Blur underlay | Hover URL view (SessionView.m:79-88) | A within-window NSVisualEffectView over terminal content. A pattern to copy. |
| Shadow | ContentNavigationShortcut.swift:89-92 | Four layer shadow properties. Trivial to copy. |
| Synthetic swap state | replaceActiveSessionWithSyntheticSession: |
Works unchanged inside a per-float split view. |
| Legacy-renderer fallback | Browser panes in metalAllowed: |
Precedent for forcing a whole tab to the legacy renderer. |
Info Plist Localization (`docs/info-plist-localization.md`)
Info.plist localization
How the app's Info.plist strings (permission-prompt descriptions, app name,
copyright) are localized, and the one maintenance invariant the build's plist
structure imposes.
What gets localized
macOS shows a handful of Info.plist values to the user: the NS*UsageDescription
strings in system permission dialogs ("iTerm2 wants to use the camera."),CFBundleName, CFBundleGetInfoString, and NSHumanReadableCopyright. Those
localizable keys are collected in sources/InfoPlist.xcstrings, whose keys are
the literal Info.plist key names (not symbolic keys). Xcode recognizes theInfoPlist.xcstrings filename and treats it as the Info.plist localization
source - it's the String Catalog successor to InfoPlist.strings.
Brand/license values (CFBundleName = "iTerm2", NSHumanReadableCopyright =
"GPL v2") are constants, so every language carries the same value as the base
plist.
Version keys (CFBundleGetInfoString, and CFBundleShortVersionString /CFBundleVersion should they ever be extracted) must have no non-English
localizations and must be marked shouldTranslate: false. The base plist's
version is stamped in at build time, but a localized value is a frozen literal:
it compiles into <lang>.lproj/InfoPlist.strings and overrides the real version
(a release once showed "3.7.dev" in Get Info for every non-English UI). Without
a localized value the OS falls back to the base plist. Note thatshouldTranslate: false alone does not stop existing translations from being
compiled; they must be removed too. LocalizationHygieneTests enforces this.
How it works (no Info.plist edit required)
The localization is a purely additive overlay - no plist had to change to enable
it:
- The base plist supplies the keys and English values. These keys are
functionally required regardless of localization (the app cannot request
camera access withoutNSCameraUsageDescription), so they already existed. InfoPlist.xcstringsholds the extracted English source plus translations,
keyed by the same plist keys.- At build time the catalog compiles to
<lang>.lproj/InfoPlist.stringsin
the app bundle (one file per language). - At runtime macOS overlays those
InfoPlist.stringsvalues onto the plist
by key for the current language, falling back to the base plist value for
any key not translated. The app itself does nothing; the OS does the overlay
when it renders permission dialogs and Finder metadata.
Adding the catalog therefore touched only sources/InfoPlist.xcstrings; the plist
was not modified, and no CFBundleLocalizations / CFBundleAllowMixedLocalizations
key was needed (the presence of the .lproj/InfoPlist.strings files is what the
OS keys off).
The plist-flavor invariant (important)
There is no single checked-in Info.plist. plists/iTerm2.plist is a
gitignored build artifact: the Makefile copies the flavor-specific source
variant over it per build target -
| target | source variant |
|---|---|
make dev (and the default backstop) |
plists/dev-iTerm2.plist |
make Beta |
plists/beta-iTerm2.plist |
make Nightly |
plists/nightly-iTerm2.plist |
make Deployment (release) |
plists/release-iTerm2.plist |
| preview | plists/preview-iTerm2.plist |
The runtime overlay is flavor-agnostic - it keys off NSCameraUsageDescription
etc., not off which variant was copied - so localization works for whichever
flavor is built.
But because InfoPlist.xcstrings carries a single English source per key,
the localizable values must stay identical across all five variants. They are
today (e.g. every variant's NSCameraUsageDescription is"An application in iTerm2 wants to use the camera.", matching the catalog).
Consequences if a flavor's wording ever diverges (e.g. a beta-only permission
message):
- The zh/other override still applies - it's keyed by the plist key, not the
English - so the diverged flavor would show the translation of the catalog's
English, which may no longer match that flavor's intended text. - The catalog's English
sourcewould be stale relative to that one flavor, and
a translator working from the catalog would never see the divergent wording.
Rule of thumb: keep the localizable Info.plist values (the usage descriptions
in particular) identical across dev-/beta-/nightly-/release-/preview-iTerm2.plist.
If a flavor genuinely needs different user-facing text for one of these keys, it
cannot be localized correctly through the shared catalog key - treat that as a
special case rather than silently diverging the wording.
Iterm2 Companion Relay Design (`docs/iterm2-companion-relay-design.md`)
iTerm2 iPhone Companion — Relay Sharding Design
Status: Draft
Author: George Nachman
Last updated: 2026-07-12
1. Overview
The iPhone companion device lets a user view a live iTerm2 session on their phone. Two kinds of data flow from the Mac to the phone:
- Live video of the active window, streamed in real time.
- Static PNG tiles of scrollback history.
Neither device can generally reach the other directly, so traffic is brokered by a relay: the Mac and the phone each open a WebSocket-over-TLS (wss://) connection to a relay process, which splices the two together. The relay is a stateful pairing point — for a given session, the Mac and the phone must terminate on the same relay process so their sockets can be joined — but sessions are independent of one another, so the relay is horizontally shardable.
Session payloads are end-to-end encrypted (E2EE): the relay forwards ciphertext and cannot read session content. It still terminates TLS on each hop (the transport), so it can authenticate and route the signaling/pairing traffic.
This document describes how the relay process works and how to shard it horizontally so that both endpoints of a pairing always rendezvous on the same host — durably across reconnections — while hosts can be added, removed, and rebalanced, and while user-run (self-hosted) relays keep working unchanged.
2. Goals and non-goals
Goals
- Both endpoints of a pairing reliably reach the same relay process, on every reconnect, indefinitely.
- Scale horizontally by adding hosts; drain and remove hosts without breaking existing pairings.
- Fail safe: transient disagreement during operations resolves to a retry, never to a silent split or a wrong pairing.
- Keep the control plane (the thing that decides "which host") from becoming a load-bearing service that must be run HA.
- Terminate TLS on infrastructure we control (signaling trust).
- Support user-run (self-hosted) relays as a first-class mode, alongside the managed, sharded fleet.
Non-goals
- Peer-to-peer / NAT traversal. There is intentionally no ICE/STUN; everything goes through the relay.
- Reading or transcoding session content. The relay is a byte forwarder; media handling stays on the endpoints.
- Server-side persistence of session state. A reconnect re-establishes the splice from scratch.
- Geo-aware placement. Sharding is geography-blind by construction (see §11); a per-geo cluster layer is deferred.
3. Background: current state
relay.iterm2.comresolves to a single VPS IP. One relay process; no sharding.The Mac generates a QR code containing a pairing URL that the mobile client scans:
textiterm2://pair?v=1&proto=Noise_XK_25519_ChaChaPoly_BLAKE2s&rs=<public key>&pid=<pairing ID>&relay=<relay URL>proto— the E2EE handshake: Noise XK (Curve25519 / ChaChaPoly / BLAKE2s). The session is end-to-end encrypted between Mac and phone; the relay only forwards ciphertext.rs— the responder's static public key (the Mac's). Noise XK pinsrsin the QR, so the phone authenticates the Mac cryptographically and no party in the middle can impersonate it.pid— the pairing ID, the durable identifier shared by both devices.relay— the relay URL the client connects to directly.
In this mode (
v=1) the client always connects directly to therelayURL. Existing beta users are already paired this way.
Direct-to-relay is simple and correct for a single relay, including user-run (self-hosted) relays — a user running their own box just points relay= at it, and this format is retained for exactly that (§6.1). What it does not do by itself is support sharding across a fleet, or let a managed service rebalance and survive host loss — which is what the rest of this document adds.
4. Requirements and constraints
| # | Constraint | Source |
|---|---|---|
| R1 | Adoption is highly uncertain; must be able to scale quickly if it takes off. | Product |
| R2 | Both endpoints of a pairing must land on the same relay process, every reconnect, durably. | Correctness |
| R3 | Must be able to reshard: add/remove/rebalance hosts without breaking pairings. | Ops |
| R4 | TLS terminates on hosts we control; signaling must be trustworthy. | Security |
5. The Node relay process
The relay is a Node.js process. Because payloads are E2EE, it cannot inspect content — it forwards bytes over WebSocket. Two per-byte costs remain:
- WebSocket unmasking. The protocol requires client→server frames to be XOR-masked, so the relay unmasks every inbound byte on the device→relay leg (relay→device frames are unmasked per spec). Cheap, and native-accelerated if
bufferutilis installed. - TLS termination.
wss://means the relay decrypts inbound TLS and re-encrypts outbound TLS on each hop, even though the payload underneath stays E2EE. Bulk TLS is AES-GCM; with AES-NI (every modern server CPU) OpenSSL runs it at multiple GB/s per core. Filling a gigabit costs a single-digit percentage of a core, even doing decrypt + re-encrypt.
The expensive part of TLS is the handshake (asymmetric crypto at connection setup), but the relay holds long-lived streaming connections, so per-byte handshake cost amortizes to ~nothing. The exception is reconnect churn (flaky mobile networks, or a mass reconnect during a reshard) — that's a spiky handshake-CPU event, addressed in §7.
Conclusion: CPU is not the bottleneck; the port is. One core roughly fills a gigabit for this workload.
5.1 Implementation notes
- Run one relay process per gigabit box. Then "same process" ≡ "same box," and there is no intra-box sharding problem — the entire sharding question reduces to "which box." (Only revisit on 10 Gbps boxes; see §11.)
- Install
bufferutil(andutf-8-validate) sowsunmasks via the native path. - Disable
permessage-deflate(perMessageDeflate: false). Payloads are already-encrypted and incompressible; deflate burns CPU for zero gain and tanks throughput. - Honor backpressure. Watch
socket.write()'s return /ws.bufferedAmount; pause the source when the destination is slow. For live video, shed rather than buffer unboundedly. - Optionally offload TLS to nginx/HAProxy on the box, with Node speaking plain
ws://on loopback. Native proxies handle TLS termination and handshakes more efficiently and keep the Node event loop lean. TLS still terminates on hardware we control (R4). This on-box proxy is per box and only ever handles its own host's traffic; it is not the central, all-seeing load balancer §6 deliberately avoids. No component carries aggregate fleet bandwidth. - Set generous idle timeouts. A parked Mac holds a long-idle socket while it waits for a phone to connect; the app keepalives about every 15 seconds, so the box's proxy and Node idle-reap timeouts must comfortably exceed that, or parked Macs are silently dropped and the failure surfaces only as "the phone cannot connect."
5.2 Transport caveat
WebSocket rides TCP, so under packet loss you get head-of-line blocking: a dropped packet stalls the stream until retransmit, showing up as a stutter/freeze rather than a graceful quality dip (as UDP/WebRTC would). This is the tax for WS's easy NAT/firewall traversal. The mitigation is app-side (the bitrate/frame-rate throttles, or dropping to tiles under sustained loss).
5.3 Bandwidth budget and per-room quota
Bandwidth, not CPU, is the cost that matters, and the motivation for self-hosting is a predictable bill. Plan for it explicitly.
- Budget in egress, not port speed. Relayed media is 2x on the box's uplink (in from one endpoint, out to the other). Put each box on a plan with a hard or alertable egress cap, so a runaway session or an abuse spike cannot produce an unbounded bill. That cap is the whole reason for running our own boxes rather than a metered cloud.
- Keep the per-room daily byte quota. It is the per-session cost bound (
DEFAULT_DAILY_BYTE_QUOTAtoday). It is host-local and resets on a move or restart, which is safe (§6.7). Tune the default up for video, but keep it finite: it is what caps one pairing's worst-case daily bytes, and it matters more now that the bytes are billed to us. - Capacity unit: N concurrent max-bitrate streams per box. A splice is single-host, so one video room cannot be split across boxes; the per-room hotness of §6.2 is the granularity floor. At a few hundred sessions per gigabit box the law of large numbers smooths the count, but a single very heavy room still lands wholly on one box, so size for the heavy case.
- The control plane carries only kilobytes. The CDN shard map (and a redirect director, if one is ever added) never touches media, so it cannot contribute to a bandwidth bill. No shared component ever sees aggregate fleet bandwidth.
6. Sharding architecture
6.1 Two rendezvous modes
The pairing URL selects how the client finds a relay:
- Direct mode (
relay=,v=1). The client connects straight to the named relay. This is what existing beta users have, and it is ideal for a self-hosted single relay: a user running their own box just pointsrelay=at it. Retained unchanged — no shard map, no resolver. - Resolved mode (
resolver=,v=2). The QR carries a resolver URL instead of a relay URL. The client fetches the shard map from that resolver, computes the owning host from the pairing, and connects there. This is the mode the managed iTerm2 fleet uses, and it is what makes horizontal sharding possible. A self-hoster who wants to shard across several of their own relays can likewise run a resolver and emitv=2.
Clients understand both; the mode is chosen by which parameter is present (relay= → direct, resolver= → resolved), with the version bumped to v=2 for resolved pairings. The rest of §6 describes resolved mode.
Why resolved mode exists (R2/R3). Both endpoints of a pairing must reach the same relay process, on every reconnect, durably. Baking a specific relay into the QR hard-binds a durable pairing to one box — fine when the user owns that box (self-hosting), but wrong for a managed fleet that must rebalance and survive host loss: if that box dies or is retired, every QR that pointed to it is dead. Resolved mode fixes this by carrying a stable resolver URL and resolving the host at connect time from roomName (derived from the rs and pid already in the QR).
6.2 Two-level mapping: roomName → bucket → host
roomName = SHA256(canonical("iterm2-room", [rs, pid])) // already computed by both devices (RelayRoom)
bucket = uint16be(roomName[30], roomName[31]) // last two digest bytes -> [0, 65535]
host = shardMap[bucket] // mutable; the reshard knobThe sharding key is roomName. Note it is not a bare SHA256(rs ‖ pid): it is the existing RelayRoom derivation, a SHA256 over a domain-separated, length-prefixed canonical encoding (CanonicalEncoding.encode with domain "iterm2-room" over the fields [rs, utf8(pid)]), rendered lowercase hex. Both devices and the relay already compute exactly this value (it is the x-relay-room header), so the shard layer must reuse it verbatim; reimplementing a plain concatenation would silently break rendezvous with every existing client. Because the output is a SHA256 it is uniformly distributed, so the bucket is just a slice of the digest: no separate hash function and no modulo bias. Uniformity holds for the number of pairings per bucket. It does not follow that bytes-per-bucket are uniform: one high-bitrate video room can outweigh many idle ones, so treat buckets as balancing connection count, not bandwidth (see the capacity note in §5.3 and the hotness caveat in §11).
N_BUCKETS = 2^16 = 65536, taken as the low 16 bits ofroomName. A power of two makes the bit-slice exactlyroomName mod 65536with zero modulo bias. 65536 buckets is ample granularity (4× Redis Cluster's 16384) — with 8 hosts you move load in ~0.0015% increments — and, like any bucket count, it is chosen once and immutable: changing it rehashes every pairing. Buckets are permanent; hosts are the movable layer underneath.- Pin the extraction exactly and identically across Swift (Mac + iPhone) and Node (hosts): the two bytes are indices 30 and 31 of the 32 raw digest bytes, big-endian (
bucket = roomName[30] << 8 | roomName[31]), i.e. the two bytes whose hex is the last four characters of the 64-charx-relay-roomheader. A mismatch here silently breaks rendezvous, so ship a test vector (a fixedroomNamemapping to an expected bucket) verified in every codebase.
Same roomName → same bucket → same host, by construction, for both devices — as long as both read the same shard map version. That last clause is what §6.4 makes safe.
6.3 The shard map is a static file on a CDN
The "resolver" is not a service — it is a static, versioned JSON file served from a CDN, mapping bucket ranges → hostnames. Its URL is the resolver= value carried in the QR:
{
"version": 37,
"ranges": [
{ "low": 0, "high": 32767, "host": "relay1.iterm2.com" },
{ "low": 32768, "high": 65535, "host": "relay2.iterm2.com" }
]
}The host is a full relay hostname (the managed fleet names them relay1.iterm2.com, relay2.iterm2.com, ...; a self-hosted resolver may use any names), so the map is domain-agnostic and no host pattern is baked into the client. Precisely, host is an authority, not a URL: a scheme, path, userinfo, query, or fragment are all disallowed. The client always prepends https:// (§6.10), so the transport is HTTPS/WSS by construction and the field never carries a scheme (direct mode is likewise https-only: the QR relay= value must be an https origin, PairingCode.canonicalRelayOrigin). The authority may be a DNS name or a bare IP literal, and may carry an explicit :port (omit it for the default 443). An IPv6 literal is written bracketed, exactly as it appears in a URL authority (for example [2001:db8::1] or [2001:db8::1]:8443), because the client forms https://<host> from it verbatim. Whatever form is chosen, that same byte string is simultaneously the box's provisioned identity, its TLS certificate name, and its origin base (§6.10, Appendix A invariant 7). The file does not carry n_buckets: it is fixed forever (Appendix A invariant 1), so the client owns the constant and the ranges simply tile [0, N_BUCKETS - 1]. A carried value could only ever be 65536, so it would be a second source of truth that must agree with the compiled-in constant; omitting it removes the mismatch entirely.
This directly retires the "resolver overloaded / unavailable" worry: a tiny static asset on a CDN is effectively always-available and unlimited-throughput. It is the well-trodden "versioned config/shard map on a CDN, polled by clients" pattern.
The bucket space is a ring, not a line segment: buckets 0 and 65535 are adjacent (the digest wraps), so 0 is not a special boundary. A host owns one or more arcs of that ring, each written as a {low, high, host} range. A single arc that crosses the 0/65535 seam serializes as two ranges sharing the same host (e.g. {60000, 65535, H} and {0, 5000, H}); a host may also hold several disjoint arcs for balancing or hot-slice isolation (§7.5). The ranges must partition [0, 65535] exactly, with no gap or overlap. This ring framing is what makes churn minimal when hosts change (§7.5).
One URL, always the latest. The resolver= value is the full URL of the static shard-map JSON (e.g. https://resolver.iterm2.com/shardmap.json); the client GETs it verbatim, appending nothing, so the configured URL is exactly the URL fetched. It is served with a short TTL (5 seconds, Cache-Control: max-age=5; Appendix C) and replaced atomically on publish, so one fetch always returns the current map. There is deliberately no separate version pointer and no second round-trip: the map carries its own version (a monotonically increasing integer counter, never a timestamp; see §7.1), so a client or relay that reads a briefly-stale copy from a lagging CDN edge just sees an older version and ignores it (monotonicity, §6.6). A short TTL bounds staleness; atomic replacement prevents torn reads. The immutable-versioned-file trick (keep the big map cached forever at shardmap-vN.json, poll a tiny pointer for changes) pays off only when the blob is large; this map is O(hosts) ranges, a fraction of a KB, so re-fetching it on every poll costs almost nothing and is not worth a second request or the pointer/map consistency it would introduce. Nothing ever loads an old version (rollback is roll-forward, §6.4), so addressing a specific version buys nothing.
6.4 Client behavior (both endpoints)
Both the phone (which joins) and the Mac (which parks and waits) run the same resolution. On (re)connect:
- Compute
roomNamevia theRelayRoomderivation (§6.2) from the QR fields, thenbucket = uint16be(roomName[30], roomName[31]). - Connect immediately to the last-known host for this pairing (a cached
bucket → hosthint — epoch-free; the client does not reason about ownership). - In parallel, fetch the shard map from the QR's
resolver=URL (which points directly at the map JSON). If it names a different host for the bucket, tear down the old connection and reconnect to the new host. - Ignore any map older than the newest version already seen (monotonicity — see §6.6).
- Classify the connect outcome; the class decides where to point next, and a shared backoff (below) decides how long to wait.
- A re-resolve signal (an HTTP 421 on connect, or a WS 4421 close on a live socket, §6.9): the host is reachable and has disowned this bucket. Force a map refresh and retry at whatever host the map now names. Do not connect to a host just because the rejecting relay named it in an
x-relay-ownerheader or close reason (that is a diagnostic hint only, §6.9); the map is the sole authority for the owner, which keeps a bad or looping config from steering clients to arbitrary hosts. - A retry-here code (429, 503, or 500 on connect; 1011, 1006, 1001, or 1008 on a live socket, §6.9): the host still owns the bucket but is busy, erroring, or restarting. Retry the same host and do not re-resolve; moving would be wrong.
- A connect failure with no HTTP response and no WS close (DNS failure, TLS-handshake failure, connection refused, timeout, or network-unreachable): ambiguous, since the host may be a transient blip or may be gone/decommissioned. Re-fetch the map. If it now names a different host, that box was removed, so connect there. If it still names the same host, or the map cannot be fetched (fall back to the cached host, §8), presume a transient outage and retry the same host under backoff.
- A re-resolve signal (an HTTP 421 on connect, or a WS 4421 close on a live socket, §6.9): the host is reachable and has disowned this bucket. Force a map refresh and retry at whatever host the map now names. Do not connect to a host just because the rejecting relay named it in an
A brand-new pairing has no cached host, so step 2 is a cache miss and it just uses the freshly fetched map.
Backoff, loop bound, and give-up. Every failed attempt, of any class, increments one consecutive-failure counter that drives a single jittered backoff schedule (RECONNECT_JITTER_INITIAL, then full-jitter exponential to RECONNECT_BACKOFF_CAP, Appendix C); a successful connect-and-splice resets it. Keeping the counter shared across all three classes is what prevents a tight loop when the where keeps saying re-resolve but the connect keeps failing: a skew-window bounce (repeated 421 while the gaining host has not yet polled, §7.4) or a flapping host is spaced out by the same backoff as any other failure. Re-resolving is always safe to repeat, since monotonic versioning (§6.6) and reject-on-doubt (§6.5) make each re-resolve idempotent, so there is no harm in consulting the map on every failure (the fetch honors the map's short TTL, so a repeat within the window is a cheap cache hit, and it too backs off if the CDN is unreachable). For the give-up policy, distinguish the roles: a party that must stay available, a parked Mac or a phone showing a live view, retries indefinitely at the capped cadence and surfaces a "reconnecting" status after a few consecutive failures so the user sees the state without the retries stopping; a foreground first-connect for a brand-new pairing, which has no established session to preserve, may instead give up after a bounded time with an actionable error and let the user re-initiate.
One resolved host per attempt. A client resolves the owning host once per connect attempt and uses that single host for the WebSocket join and every room-scoped HTTP call of that attempt (/attest/challenge, /attest, /register, /delete), so it never registers on one host while parking or joining on another. On a 421 from any of them it re-resolves and restarts the affected sub-flow from the beginning rather than resending the same request to the new host: challenges, tickets, and registration tokens are host-local (§6.7) and the App Attest clientData is origin-bound (SHA256(canonical("iterm2-relay-attest", [challenge, origin])), §6.10), so a value minted on the old host is not merely unknown at the new one, its origin is wrong and it would fail verification there. In practice a 421 mid-establishment just re-runs the self-healing attest/register sequence of §6.7 against the new owner. That origin binding is also the backstop: even a call that slipped through to the wrong host could not be accepted, because its challenge and assertion are bound to that host's origin.
The Mac re-parks on a reshard. Because the Mac holds a long-lived park, moving a bucket must move its park too: the old host closes the Mac's connection for the moved bucket (§6.5), and the Mac re-resolves and re-parks on the new owner. Rendezvous depends on this. If the Mac did not follow the map, a phone that resolved to the new host would find no Mac parked there. The phone's reconnect and the Mac's re-park converge on the new host once propagation completes.
Persist the highest version seen. Monotonicity (§6.6) must survive an app relaunch, or a freshly launched client could accept an older map from a stale CDN edge; store the last-seen version durably. A consequence: rollback is roll-forward. A bad map vN is corrected by publishing vN+1, never by republishing a lower version, which every actor would ignore.
6.5 Host behavior
A host is resharding-aware: it holds its current owned-set, and on every map reload it diffs the new owned-set against the old one to derive what it just acquired and what it just relinquished, then treats the two asymmetrically.
- On boot, fetch the map before accepting connections (so it knows which buckets it owns).
- Newly admit a connection only if the host's own copy of the map says it owns that bucket; otherwise reject ("reject-on-doubt"), signaling the client to re-resolve with HTTP 421 (§6.9). If the host knows the current owner it may add an
x-relay-ownerheader naming it, but only as a diagnostic hint: the client re-resolves via the map rather than trusting the header (§6.9), so this is not an authoritative redirect and does not save the map round-trip. - The room-scoped HTTP endpoints reject-on-doubt on the same rule.
/attest/challenge,/attest,/register, and/deleteall carry the room header, so a host applies the identical ownership gate and returns HTTP 421 (§6.9) when it does not own the bucket. Check ownership early for these, before reading the body, running the App Attest verification, or charging the/attestrate limiter, so a stale-map bounce is neither counted as an attestation failure nor allowed to burn the client's limiter budget. Unlike a live splice these calls are one-shot and hold no ongoing connection, so there is no drain defer for them: the moment a host sees it has lost a bucket it rejects that bucket's HTTP calls (the §7.4 defer keeps only the already-established splice alive, never the data-plane calls). Accepting one on a non-owner would merely strand host-local state (a challenge, a ticket, a registration token, or a verifier) that the true owner cannot see, dead-ending the pairing./deletestays best-effort under this rule: during a move it may reach the new owner, which has no such room, while the live splice finishes draining on the old owner; the drain and the client closing its own sockets both reclaim the room, so teardown still completes and it is deliberately not special-cased to chase the live-room host. - Reload on version bump (periodic re-fetch of the map, §6.8), then apply the diff:
- Newly acquired buckets are usable immediately. Begin accepting for them at once; there is nothing to ramp, since a client reaches this host only because the map already named it the owner. An established room whose verifier this host lacks simply self-heals on first contact (§6.7).
- Relinquished buckets are evicted gradually, and only after a delay. New joins for a relinquished bucket reject immediately (reject-on-doubt, so the split never grows), but existing spliced rooms keep running untouched for
RESHARD_DRAIN_DELAY(default 2x the poll interval, §7.4), by which point the gaining host has almost certainly also polled and is already accepting. Only then does the host begin closing those rooms, at a configured rate of X rooms/second, each close using the WS 4421 re-resolve code (§6.9) to prompt its clients to re-resolve to the new owner.
- A successfully fetched map that assigns this host zero buckets is a valid state, not an error. It means "drain to empty" (a decommission): acquire nothing, evict everything gradually, then sit idle while still polling. This must be distinguished from a fetch failure below: a published empty assignment drains; an unreachable map does not.
- On fetch failure, keep last-known-good — never fail-open (accept everything) or fail-closed (reject everything) on a CDN blip, and never confuse "couldn't fetch" (hold) with "fetched, own nothing" (drain).
Each host only needs to know the buckets it currently owns (plus the ones it is mid-drain relinquishing) — never the global map, never other hosts' assignments. So there is no distributed map-replication problem; there is a small local view that only has to be safe when stale, not synchronized.
6.6 Why races are safe (the correctness argument)
Separate two properties. The local rule, always held: a host serves a bucket only if its own current map version says it owns it. The global property, a single accepting owner at any instant, holds only under two-phase publish or after propagation converges; single-write publish deliberately relaxes it to a brief self-healing split (§7.2). The local rule is what prevents a wrong accept, and it is upheld not by synchronizing views but by:
- Reject-on-doubt: a host newly admits a bucket only if its own current view says it owns it. Any disagreement between a client's cache, a CDN edge, and a host's view collapses to reject → retry, never to a wrong accept. Reject-on-doubt governs new joins; a relinquished bucket's existing rooms drain at the eviction rate (§7.4), so their split window is bounded by drain time rather than propagation time. That is a longer but still bounded, still safety-preserving window: a rendezvous glitch at worst, with Noise backstopping security throughout.
- Monotonic versioning: every actor ignores any map older than the newest it has seen. Without this, multi-edge CDN version skew (edge A serves v37, edge B still serves v36) makes clients flap between hosts and the system may never visibly settle. With it, every actor moves strictly forward, and once publishing stops, everyone converges on the highest version.
The client's optimistic "connect to cached host, verify in parallel" and the host's reject-on-doubt compose cleanly: the only time a cached reconnect misses is during a reshard, and a reshard is an operator/driver-initiated event — so the map (CDN) is by definition being served at that moment. Cache-miss coincides with map-availability. There is no steady-state dependence on the control plane at all.
6.7 Relay state model and self-healing reconnect
Each host keeps three kinds of per-room state. Classify them by whether they must survive a host change, and none of them forces migration between hosts.
- Ephemeral (single-use tickets, registration tokens, App Attest challenges, live sockets, in-memory rate windows). These do not survive a hibernation or restart today; on a bucket move they are simply gone and the client retries. Nothing to move.
- Cost (the per-room daily byte quota, §5.3). Host-local; it resets when the bucket moves or the host restarts. Safe, because a bucket move is an operator-initiated event an attacker cannot trigger, so a reset is not an abuse lever. Worst case a room slightly exceeds its daily budget across a reshard.
- Reconnect-auth (
verifier,registrantKeyId, assertion counter). The only durable-looking state, and it is soft state. The join verifier is deterministic:verifier = HKDF(roomSecret)(RelayJoin), androomSecretis persisted on both devices (the phone mints it and keeps it; it is couriered to the Mac over the Noise channel and kept there). Both roles sign their joins with that one shared key (over the(protocolVersion, role, nonce, roomName, origin)transcript of §6.10, notroomNamealone), and the relay stores only the public verifier. So a host that lacks the verifier just treats the room as unestablished, and the phone re-registers the identical value.
Switching relays means re-establishing on the new one. Worth stating plainly, because it is the client's entire obligation on a reshard: whenever a client is moved to a different relay (a reshard, a host restart, or a host death), the destination is almost always a box that has never held this room, so it holds no verifier and the room must be established there afresh. That re-establishment is silent (no QR, no SAS, no user prompt) and asymmetric between the roles: the phone re-runs the attest-and-register dance, while the Mac only re-parks and needs no action. The phone does not re-attest speculatively; it tries its normal signed join first and falls into the dance only when the new host answers "unestablished". Because reshards are rare and operator-initiated, this cost (including an occasional fresh App Attest key) is paid at reshard cadence, not routinely.
Self-healing flow. When a bucket lands on a host that has no verifier for it (a fresh box after a move, restart, or death):
- The phone reconnects and attempts a signed (established) join.
- The host, having no verifier, is in pairing mode and answers
"ticket required"(or the explicit"unestablished"signal of §6.5). - The phone falls back silently, with no QR and no SAS: it runs App Attest to earn a ticket, reconnects with it, receives a registration token, and re-registers
verifier(roomSecret). It may mint a fresh App Attest key; the relay lostregistrantKeyIdtoo, so nothing needs to match, and the new key's assertion counter starts fresh. - The Mac needs no action. On a verifier-less host its signed park is admitted as a pre-auth park (the signature is ignored in pairing mode), and once the phone re-registers, the Mac's later signed parks verify again against the restored verifier, because both sign with the same
HKDF(roomSecret)key.
The counter reset is replay-safe: assertions are bound to single-use, server-minted challenges, and each host mints its own, so a captured assertion cannot be replayed on a different host regardless of the counter. The counter is defense in depth on top of challenge freshness, so resetting it opens no window.
The residual cost (DoS-only). Each verifier loss reopens the first-registration slot on the new host. In the window between the move and the phone's re-registration, an attacker who both knows the roomName and can pass App Attest could register a bogus verifier and lock the legitimate devices out: their signed joins would then fail to verify. This is availability-only. Noise XK still authenticates the Mac end to end, so a bogus verifier can block reconnect but cannot read content or impersonate an endpoint. It is doubly gated (roomName secrecy, meaning a scanned QR, plus App Attest, meaning the real app on a real device), and the window shrinks with prompt, jittered phone reconnect (§7.3). Recovery is to re-pair. §11 lists an address binding that would close the race outright.
This is what lets Appendix A invariant 5 hold by construction: the relay keeps no durable per-pairing state that must survive a host change, so a bucket's home is reassignable at will and a dead host hands nothing off.
6.8 How relays learn of map changes
Relays learn of a new map the same way clients do, by periodic polling of the CDN map file (§6.3), not by a push from a central coordinator. This preserves the "no load-bearing HA control plane" goal: there is no fleet roster to maintain and no host to actively notify, and a rebooting relay just fetches the current map on boot.
- Poll cadence. Each relay re-fetches the short-TTL
shardmap.jsoneverySHARDMAP_POLL_INTERVAL(default 10 seconds; a few seconds to a minute, operator's choice; Appendix C); when the fetched map'sversionexceeds the one it holds, it applies the §6.5 diff (an equal-or-older version, e.g. from a lagging edge, is ignored). - Monotonic + integrity, same as clients (§6.6, §9). A relay polling a multi-edge CDN can read an older version from a lagging edge, so it ignores any version older than the newest it has seen. It fetches over HTTPS from our domain; map signing is not required today (§9), but if it is ever added the relay verifies the signature before adopting a map, since it enforces ownership from this file.
- Reload latency bounds reshard convergence. A relay that has not yet polled keeps accepting for buckets it lost and has not begun draining relinquished ones. Correctness holds (reject-on-doubt + monotonic + bounded split, §6.6), but the reshard has not taken effect on that host until it reloads. So
SHARDMAP_POLL_INTERVALis the knob for how fast a reshard lands (traded against poll traffic), and it is also what sets the drain-start defer of §7.4. - Config, not QR. Relays get the map base URL and poll interval from their provisioning config; clients get the resolver URL from the QR. A managed relay is also provisioned with its own hostname, the single string that is its TLS-cert name, its map identity (the
hostentries it owns), and the base of its proof origin (§6.10). - Optional push is trigger-only. If lower latency is ever wanted, a push may tell relays "a newer version exists, go fetch," but it must never carry map contents (the CDN stays the single source of truth), and polling stays the floor so a missed push cannot strand a host.
6.9 Re-resolution wire codes
Sharding adds one new instruction the relay must be able to give a client: not here, go re-resolve. Every item in the client and relay work lists depends on these numbers being fixed, so they are pinned here rather than left to each implementation to invent. There are two re-resolve codes, because the signal originates from two different places, and every pre-existing code keeps its "retry the same host" meaning.
Re-resolve (leave this host). The client must not retry the same host. It refetches the shard map and connects to whatever host the map names (the owner hint, if any, is diagnostic only, below).
| Origin | Code | Cause |
|---|---|---|
A WebSocket upgrade, or an HTTP data-plane request (/attest, /register, /delete) |
HTTP 421 (Misdirected Request) | The host does not own this bucket (reject-on-doubt, §6.5), or the bucket currently has no owner (two-phase drain, §7.2). |
| A live, already-spliced WebSocket | WS close 4421 | The room was evicted by a reshard; its bucket moved (§7.4). |
421 is chosen because RFC 7540's "misdirected request", a request that reached a server unable to produce the response, is precisely reject-on-doubt. 4421 lives in the WebSocket private-use range (4000 to 4999) and deliberately echoes 421, so both re-resolve signals carry the same number across the two code spaces. They remain distinct codes with distinct handling: 421 is returned before or without an open socket (admission time), while 4421 closes a socket that is already spliced (eviction time). A client cannot confuse the two, and neither belongs to the retry-here set below.
Because some client WebSocket stacks (for example URLSessionWebSocketTask) expose only a fixed enum of close codes and cannot surface an arbitrary 4xxx number, the 4421 close also carries a machine-readable reason that begins with the ASCII sentinel reshard. A client detects re-resolve as closeCode == 4421 OR reason begins with reshard. This mirrors the existing daily-quota close, already matched on the client by both its 1008 code and a daily quota reason substring (RelayWebSocket.swift), so the belt-and-suspenders pattern is not new.
Owner hint (optional, diagnostic only). A rejecting or evicting host that knows the current owner from its own map may name it, to aid logging and debugging (not to redirect the client):
- On HTTP 421: an
x-relay-owner: <hostname>response header. - On WS 4421: the reason is
reshard <hostname>(the owner follows the sentinel and a single space).
The client never connects to a host just because a relay named it. On a re-resolve it derives the target from the shard map itself (§6.4), so a relay can only ever say "not me," never dictate where to go; the map is the sole authority and wins on any disagreement. This is deliberate hardening: trusting a relay's redirect would let one bad or malicious config bounce clients in an endless loop (X says go to Y, Y says go to X), whereas taking every target from the map means clients only ever reach map-sanctioned hosts, and any residual loop (e.g. a lagging CDN edge still naming the evicting host) is spaced out and bounded by the shared reconnect backoff (§6.4). A host in the two-phase "no owner" state, or any host that omits the hint, sends the bare form; the client behaves identically either way, since it re-fetches regardless. The trade-off is that the hint no longer saves the map round-trip it was once meant to, but that round-trip is cheap (a fraction-of-a-KB file, usually a cache hit within its short TTL), and the robustness is worth more.
Retry here (transient; keep the cached host). Unchanged by sharding, and explicitly not a re-resolve. Back off with jitter and reconnect to the same host.
| Code | Cause | Client action |
|---|---|---|
| WS close 1001 | Host going away (graceful restart or shutdown, §7.1) | Reconnect to the same cached host after a short delay. If the box was decommissioned but is still up (draining), the map has already moved the bucket and this reconnect earns a 421, which then re-resolves; if the box is fully down, the reconnect is a connect failure instead, handled by §6.4's connect-failure class (re-fetch the map, land on the new owner). Either way 1001 needs no dedicated retire code. |
| WS close 1011 or 1006 | Internal error or abnormal close | Same host, jittered backoff. |
| HTTP 429 or 503 | Rate limited or at capacity | Same host, jittered backoff. |
| HTTP 500 | Transient internal error | Same host, jittered backoff. |
Long backoff (same host, deliberately long delay). Matched by close code plus reason, exactly the sentinel pattern above. These are same-host (not a re-resolve), but must not use the ordinary short jittered backoff.
| Code + reason | Cause | Client action |
|---|---|---|
WS 1008 + reason daily quota exceeded |
Daily byte quota exhausted (§5.3). Host-local; resets on the next day or on a bucket move. | Same host, long backoff (30-minute cadence, RelayWebSocket.swift → CompanionPairingController.quotaListenerRetryNanos), so a client does not hammer an exhausted quota. |
WS 1000 + reason displaced |
A newer connection for the same role took the room's single mac (or phone) slot. Both duplicate instances of the pairing resolve to the same host, so this is emphatically not a re-resolve. | Same host, long backoff (60 s, RelayDisplacedError → displacedListenerRetryNanos), so two duplicate instances do not ping-pong evicting each other. |
These are reason-disambiguated; do not key off the bare number. The relay reuses 1008 for several transient conditions too (frame rate exceeded, bad hello, too many pending, admission timeout, and generic errors) and 1000 for an ordinary normal-closure, so only the two reasons above take the long backoff; any other 1008 or 1000 is a transient retry-here (short jittered backoff, same host), which the client decides from the reason text (RelayWebSocket.swift). Implementing the retry-here rule by code alone ("every non-421/4421 code → short jittered retry") would give a displaced Mac a 3-second retry and reintroduce the very eviction storm the 60-second backoff exists to prevent.
Fatal (do not blind-retry). HTTP 403 (entry-gate reject) and 413 (payload too large) are client or configuration errors, not transient. Surface them instead of looping.
The rule that ties this together: a re-resolve code (421 or 4421) is the only thing that moves a client to a different host; every other code keeps it on the one it has. That single invariant is what lets cached-host optimism (§6.4) and reject-on-doubt (§6.6) compose, and it is why the retire path needs no code of its own.
6.10 Host identity and the proof origin
Three of the relay's checks bind an origin into the bytes they verify, and the design has so far been silent on it. All three feed the fields through the same domain-separated, length-prefixed canonicalEncode(domain, fields) of §6.2 (a leading domain string, then each field prefixed by its 4-byte big-endian length), never a bare concatenation, so a field boundary can never be shifted to forge a colliding input, and each domain keeps one signed message from ever being replayed as another. Every transcript's first field is a 1-byte protocolVersion (currently 1), so a signature or attestation is valid only for the protocol version that produced it:
- The admission (join) transcript both roles sign is
canonical("iterm2-relay-join", [protocolVersion, role, nonce, roomName, origin])(RelayJoin), notroomNamealone. - The App Attest clientData the phone attests and asserts over is
SHA256(canonical("iterm2-relay-attest", [challenge, origin]))(the challenge is the relay's nonce; the domain separation makes the challenge/origin boundary unambiguous). - The delete-room transcript that authorizes an unpair is
canonical("iterm2-relay-delete", [protocolVersion, challenge, roomName, origin]).
In each case the client signs or attests over the origin and the relay recomputes it and verifies. If the two strings differ by a single byte, every signature and attestation fails, and the symptom is not a connection error (the socket connects fine) but "bad signature" on every join. The origin is therefore a correctness-critical shared constant that both sides must derive identically. It is invisible at the level §6.7 and §9 describe the join crypto ("both roles sign their joins"), so a fleet built from that description alone would ship with every signed join failing, which is why it is called out here.
One provisioned hostname, three uses. A managed box is provisioned with exactly one hostname (for example relay1.iterm2.com), and that single string is the source of truth for three things that must never disagree:
- Its TLS certificate: the cert SAN is that hostname (the on-box proxy of §5.1 obtains a certificate for exactly that name).
- Its map identity: the box owns the buckets of every shard-map range whose
hostfield equals this hostname. This is the answer to "which line of the map is mine" (§6.5). - Its proof origin:
origin = "https://" + hostname.
Because all three derive from the one provisioned value, they cannot drift on a correctly configured box: the name the client reached over TLS, the name that assigned it the bucket, and the name in the signed transcript are the same name.
The origin is server-side, never the Host header. The relay takes its origin from provisioning config, not from the incoming request's Host header. A client-supplied header must not be able to move the signed bytes, and the on-box proxy may rewrite Host anyway. The client does not need to send the name for the relay to agree on it: the client connected to https://hostname and TLS validated the certificate whose SAN is that hostname, so the certificate is the shared anchor that pins both ends to the same string with no trusted header in the loop.
The client's construction rule. In resolved mode the client forms origin = "https://" + host, with the shard-map host field taken verbatim and no normalization (ShardHostResolver). So the published map host, the box's provisioned hostname, and the origin the relay verifies must be byte-identical. To keep them so, the token stays in one canonical form everywhere: a bare authority (never a scheme or path, since the client always prepends https://), lowercase ASCII (punycode a-labels for an IDN), no trailing dot; it may be a DNS name or a bare IP literal (IPv6 bracketed, e.g. [2001:db8::1]); and no port unless the box serves on a non-443 port, in which case the host:port authority is the token in all three places. In direct mode the origin instead comes from the QR's relay= value, canonicalized identically on both devices (PairingCode.canonicalRelayOrigin), and the self-hosted box is configured to match; no map is involved.
So publishing a box into the map is not only a routing edit. The host string written into a range is simultaneously the name that box's certificate must cover and the origin its clients will sign against, so it must equal the box's provisioned identity exactly. A mismatch does not fail at connect time; it silently breaks every proof for that box's buckets.
7. Resharding and operations
7.1 Add / remove / rebalance
To make any sharding change: update the shard map file, bump the version. Hosts reload and drop connections for buckets they no longer own; those reconnect and land on the new owner. Clients re-fetch on the version bump (or on rejection) and follow. It doesn't matter if hosts reload at slightly different times — reject-on-doubt + monotonic versioning make the interleaving safe, and it settles once propagation completes.
version is a monotonic integer counter, not a timestamp. Each publish increments it by at least one; it never repeats and never moves backward. A counter rather than a wall-clock time because monotonicity (§6.6) needs a total order that cannot regress on clock skew and cannot collide when two edits land close together, and because rollback is roll-forward (§6.4): the only valid successor to vN is a higher number, which a counter makes unambiguous. Every actor compares versions numerically and adopts only strictly-greater ones (the wire type is a non-negative integer; ShardMap rejects a negative version).
Publishing is owned by a single pipeline (a to-be-built tool). A web-based admin tool will be the sole writer of shardmap.json: it holds the authoritative counter, increments it on each change, and replaces the file atomically (write-new-then-rename, or an atomic CDN put) so no reader ever sees a torn map and no two publishes can select the same or a lower version. Concentrating writes in one tool is what makes the atomic-publish and monotonic-counter guarantees hold in practice. Hand-editing the file out of band would risk a repeated or non-increasing version (which every actor would then ignore, silently stranding the change) and must be avoided; the tool is the enforcement point for both invariants.
- Add a host: provision it, give it a DNS name under the wildcard, publish a map that carves it some bucket ranges (minimal-churn carve, §7.5).
- Decommission / drain a host: reassign its ranges to live hosts in the map (merge into neighbors, §7.5), publish; the draining host closes those connections; retire the box once empty. Draining depends on the host actually observing the new map (it closes moved buckets only on reload); a host partitioned from the CDN keeps serving under last-known-good (§8), so a host that cannot reach the map must be hard-stopped to complete the drain.
- Rebalance: move bucket ranges between hosts in the map. Prefer the minimal-churn edits of §7.5; a full re-equalization is a deliberate, higher-churn event.
7.2 Split vs unavailability during a move (choose per taste)
When a bucket moves from H_old to H_new, there is a propagation window bounded by the max staleness of any host's view (map TTL + host poll interval; shrink further with CDN purge-on-publish).
- Single-write publish (simplest): brief, self-healing split possible — if a client reads the new map before
H_oldhas evicted its copy of the room (§7.4), one endpoint can be onH_oldand the other onH_newuntilH_oldevicts that room and kicks the straggler. Gradual eviction paces these per room at the drain rate rather than dropping them all at once. For real-time video each is a few-second reconnect glitch during an operator-initiated reshard. Recommended default. - Two-phase publish (zero split): publish the bucket as "draining / no owner" (both hosts reject it with a bare HTTP 421, §6.9, so clients re-resolve and retry), wait one reload interval, then publish the new owner. This is break-before-make: brief unavailability for that bucket instead of a brief split, and never two accepting owners. Reach for it only if a split-glitch ever proves unacceptable.
Do not do make-before-break (assign the new owner before the old relinquishes) — that's the one ordering that permits two simultaneous accepting owners.
7.3 Reconnect / handshake storms
A reshard (or a host death) kicks a batch of connections that all reconnect at once, and each reconnect is a TLS handshake — the expensive part of TLS. Mitigate by:
- Resharding gradually — move a few bucket ranges per publish, not a whole host at once.
- Jittered client reconnect backoff — spread the handshake load over time. Use full jitter (a uniform random delay, the flattest scheme for a synchronized herd; equal/decorrelated jitter re-clumps it). The first reconnect after an eviction (4421), a reject (421), or a dropped socket waits a uniform
[0, RECONNECT_JITTER_INITIAL](default 3 s); each subsequent failure backs off with full-jitter exponential,delay = uniform(0, min(RECONNECT_BACKOFF_CAP, RECONNECT_BACKOFF_BASE · 2^n))(defaults 1 s base, 30 s cap,n= failed attempts so far). The 3-second initial window keeps the user-visible glitch short (§7.2) while flattening a whole-host reconnect to a manageable arrival rate. The herd only forms on an ungraceful host death; a graceful reshard is already paced by the eviction rate below, so jitter is mostly the death-case backstop. See Appendix C. - Rate-limited eviction on the relinquishing host — the drain of §7.4 is itself a primary handshake-storm control, capping the reconnect rate a move imposes on the new owners.
7.4 Gradual eviction of relinquished buckets
Acquiring and relinquishing buckets are deliberately asymmetric:
- Acquire fast. A newly assigned bucket is usable the instant the host sees the map. There is nothing to ramp: the host is only declaring willingness to serve, and a client reaches it only because the map already named it the owner. Established rooms whose verifier this host lacks self-heal on first contact (§6.7).
- Release slow. Relinquishing means actively closing live splices so those rooms reconnect elsewhere, and each reconnect is a TLS handshake on the new owner (§7.3). Dropping a whole host's rooms at once is a handshake storm. So a host evicts relinquished rooms at a configurable rate, X rooms/second (
RESHARD_EVICTION_RATE, default 10 rooms/second), rather than all at once.
Mechanics:
- Defer the start of the drain. With periodic polling (§6.8), the host that gained a range and the host that lost it see the new map at different times, up to one poll interval apart. If the loser began evicting the instant it reloaded, it could kick rooms toward a gainer that has not polled yet and would reject them (reject-on-doubt), bouncing those clients until the gainer catches up. So the loser waits
RESHARD_DRAIN_DELAY(default 2xSHARDMAP_POLL_INTERVAL= 20 s at the default poll) after it observes the relinquishment before starting to drain: one interval covers the worst-case poll-phase skew between two hosts reacting to the same publish, and the second is margin for map TTL, CDN edge propagation, and poll jitter. Newly acquired buckets are still accepted immediately, there is no downside to accepting early, only to evicting early. Worked example, 10-second poll: a host that at reload sees it lost buckets 0-1000 and gained 2000-3000 accepts 2000-3000 at once, leaves the 0-1000 rooms running, and only at the 20-second mark begins draining 0-1000 at X rooms/second. If a newer map arrives during the delay, recompute the deadline from that latest change (or cancel the drain entirely if the bucket is re-acquired, below). - Evict per room, atomically. The unit is a room (both its endpoints), not a socket. Close the Mac and phone sockets for a room together so they re-resolve and re-land on the new owner together; closing one but not the other would strand the peer until it drifts.
- New joins for a relinquished bucket reject immediately, even during the defer window. Only existing spliced rooms drain slowly; the host never newly admits a bucket it no longer owns. The motivation is to avoid the worst outcome, the Mac and the phone ending up on different servers. An existing room is already whole on this host (both endpoints were spliced here), so it stays consistent until drained atomically; but if the host accepted a latecomer for a relinquished bucket, that endpoint would land here while its peer resolves via the fresh map to the new owner, splitting the pairing across two servers. Worse, that wrongly-placed client would not be corrected until the drain reaches it, which is up to
RESHARD_DRAIN_DELAYplus its position in the eviction queue later, a long time to sit split. Rejecting immediately instead bounces the newcomer to the new owner at once, so both endpoints converge there together, and keeps reject-on-doubt intact, bounding the split (§6.6) to rooms that were already live at publish time. - A lone parked Mac widens the phone's reconnect gap to about the drain defer. For a room with both endpoints live, the atomic per-room eviction migrates the splice cleanly (one reconnect glitch, §7.6). But a pairing whose Mac is parked with no phone connected is treated as an existing connection and is not evicted from the losing host until the
RESHARD_DRAIN_DELAY+ eviction-queue delay elapses, so it does not re-park on the new owner until then. A phone that connects during that window resolves (via the fresh map) to the new owner, finds no Mac parked there yet, and gets "mac offline", which is a retry-here (§6.9), not a re-resolve, so it simply retries at the capped backoff until the Mac drains and re-parks. The pairing still converges and stays safe, but the user-visible reconnect latency in this specific case is on the order of the drain defer (about 20 s at the default poll), not the few-second splice-migration glitch. Left as-is deliberately: reshards are rare and operator-initiated (§7.4), and evicting lone parks eagerly to shrink this window would add a special-case path for a rare, non-urgent event. - Signal re-resolve on the close. Evict with the dedicated WS close code 4421, whose reason begins with the sentinel
reshardand may carry the new owner (§6.9), so the client skips its cached-host optimism and refetches the map (or jumps straight to the named owner), landing on the new owner in one step. - Cancel eviction on re-acquire. If a later map returns a still-draining bucket to this host, drop it from the eviction queue: it is ours again, and its live rooms never needed to move.
- The rate is the fleet knob. X bounds the reconnect/handshake load a drain imposes, and that load lands on the new owners, so size X to what a single target host absorbs in handshakes per second. If a drained host's ranges fan out to several new owners the per-target rate is lower still, and jittered client backoff (§7.3) spreads it further.
Draining a host to empty is just this with every bucket relinquished: the host acquires nothing, waits RESHARD_DRAIN_DELAY, evicts all its rooms at X/second, and once empty sits idle (still polling) until retired (§7.1). A published map assigning it zero buckets is the trigger, and it is a normal state, distinct from a failed fetch (§6.5). End-to-end, a bucket's move takes roughly RESHARD_DRAIN_DELAY plus rooms / X to fully converge; reshards are not urgent, so this latency is a feature (it paces the handshake load), not a cost.
7.5 Minimizing churn when adding or removing a host
The map is edited to move as few buckets as possible, so a membership change disturbs only the buckets that actually changed owner (and, by extension, only their rooms need to re-resolve and drain). This is consistent hashing, specialized to the bucket ring of §6.3: each host owns one or more contiguous arcs, and the minimal-churn property is a discipline on how the map is edited, not a separate algorithm or a live hash function.
Two rules, both operations on the ring:
- Add a host: carve its arc out of existing arcs, straddling a boundary so it pulls roughly
1/Kfrom its neighbors (K = new host count). Only the carved buckets move. Existing boundaries elsewhere on the ring do not move, so their buckets stay put. - Remove a host: merge its arc into an adjacent host (or split it across a few). Only the departed host's buckets move.
Worked example (the ring makes the seam a non-issue):
1 host: H1 owns the whole ring
+H2: H1 [0..32767] H2 [32768..65535] // ~1/2 moves, to H2
+H3: H1 [0..21844] H3 [21845..43689] H2 [43690..65535]
// H3 carved straddling the old H1|H2 boundary: ~1/3 moves, split from both.
-H3: H1 [0..43689] H2 [43690..65535] // only H3's buckets move, into H1Each step moves the theoretical minimum (~1/K onto a newcomer is exactly what balance requires), and the map stays O(hosts) ranges.
- The seam is not special. Because the space is a ring (§6.3), a carve or merge may cross the
0/65535seam; the affected host then holds a wrap arc, written as two ranges. Since buckets are uniform, which point is called0is immaterial, so you can equally keep every host to a single non-wrapping range by choosing cuts that avoid the seam. That is a cosmetic preference, not a requirement. - Multiple ranges per host are normal. They arise from a wrap arc, from carving a hot sub-range out of the middle of an arc (§6.2 hotness: the original host keeps the two flanking pieces), or as a balancing lever (hand an under-loaded host a second small arc). The map format is already a list, so this needs no schema change.
- Uniform buckets mean equal width is equal load. A room's bucket is a slice of a SHA256, so balancing is just sizing arc widths by eye; there is no skew to model.
- Rendezvous / scatter hashing was rejected on purpose. It assigns each bucket independently, producing a sprinkle of non-adjacent buckets per host that cannot be expressed as a few contiguous ranges. It would explode the compact range file into thousands of fragments and is harder to reason about and configure. Contiguous arcs keep the map small and operator-legible.
- The equal-vs-minimal tension. You cannot keep arcs exactly equal and minimize churn: equalizing moves boundaries, and every moved boundary migrates buckets. Day-to-day add/remove follows the minimal-churn rules and lets widths drift slightly; when drift (or an accumulation of hot-slice carve-outs) gets untidy, do a deliberate rebalance, republish a cleaned-up, roughly-equal map as one planned, higher-churn event, paced by the same defer + drain machinery (§7.4).
8. Failure modes
| Scenario | Behavior | Mitigation |
|---|---|---|
| Host dies | Its pairings drop; reconnects to it fail. | Reassign its ranges in the map; clients re-resolve to the new owner and the phone re-establishes the soft verifier on the new host (§6.7). Nothing migrates. |
| Verifier absent on the owning host (after move/restart/death) | Host treats the room as unestablished. | Phone silently re-attests and re-registers the deterministic HKDF(roomSecret) verifier (§6.7); the Mac recovers with no action. |
| Attacker grabs the verifier slot during the re-register window | Legit signed joins fail to verify; reconnect blocked. DoS only; content stays safe via Noise. | Gated by roomName secrecy plus App Attest; prompt jittered phone reconnect shrinks the window; recover by re-pairing (§9, §11). |
| Shard map (CDN) briefly unavailable | Steady-state reconnects still work off cached host + host-side ownership check. Only new pairings and reshards need the map. | CDN availability; last-known-good on hosts; monotonic client cache. |
| CDN version skew across edges | Clients could read an older map after a newer one. | Monotonic versioning — ignore older-than-seen. |
| Bucket-move window | Brief split (single-write) or brief unavailability (two-phase). | Bound the window (short TTL + purge); pick the publish strategy in §7.2. A single-write split ends when H_old evicts the room with WS 4421 (§6.9, §7.4). |
| Reconnect/handshake storm on reshard | Spike of TLS handshakes on target hosts. | Gradual reshard + jittered reconnect backoff. |
| Stale client cache after a move | Client reconnects to old host. | Host rejects buckets it no longer owns (HTTP 421, §6.9) → client re-resolves. |
| Fetch error on a host | — | Keep last-known-good; never fail-open/closed. |
9. Security and trust
- Noise XK pins the Mac's static key. The QR's
rsis the Mac's static public key, and theNoise_XK_25519_ChaChaPoly_BLAKE2shandshake authenticates it end-to-end. A rogue relay — or a client sent to one by a tampered shard map — cannot impersonate an endpoint, because it lacks the Mac's static private key and the Noise handshake fails. This is the cryptographic backstop that makes a bad map a DoS-only event. - TLS terminates on hosts (or on-box proxies) we control, not at a third-party edge — so signaling cleartext and certs live only on our own infrastructure (R4).
- Serve the shard map over HTTPS from a domain we control, so it can't be MITM'd to redirect clients to a rogue host. The resolver URL itself arrives inside the QR, so a valid QR already anchors trust in the right resolver. Signing the JSON is not required, and is not done today. HTTPS covers tampering in transit, and E2EE/Noise bounds the blast radius of a bad map to DoS plus metadata exposure (never content, per the points above), so a signature would only guard the narrow case of the CDN/origin itself being compromised. It can be added later if wanted, most plausibly if the map is ever fronted by a third-party edge that terminates our TLS (which would put that operator in the trust path); see §11.
- E2EE bounds the blast radius of a bad/tampered map to misrouting/DoS: per the Noise point above, a rogue host cannot join a pairing without the Mac's static key, so it cannot compromise content — only availability.
- A relay can reject, but never redirect. A client takes its connect target only from the shard map, never from a host's claim about who the owner is (the
x-relay-ownerhint is diagnostic only, §6.9). So a compromised or misconfigured relay cannot steer clients to a host of its choosing or loop them between hosts; the worst it can do is refuse, after which the client re-resolves against the map and any retry is bounded by the shared backoff (§6.4). - The pairing ID (
pid) is effectively a bearer credential for routing/pairing; give it sufficient entropy. (It does not gate content, which is protected by Noise/E2EE.) - The join verifier is soft state, not a secret the relay must protect. It equals
HKDF(roomSecret), both devices re-derive it, and the relay stores only the public value, so it can be lost (host move, restart, death) with no loss of confidentiality. This is what §6.7's self-healing rests on. - The signed transcripts bind the relay origin. The join and delete transcripts and the App Attest clientData all include
origin = "https://" + host(§6.10), so a proof made for one relay host cannot be replayed against another, and a box cannot be steered to verify against an origin a client supplied (the origin is server-side, anchored to the box's TLS cert). Client and relay derive the string from the same provisioned hostname (the maphostin resolved mode, the QRrelay=value in direct mode); a byte mismatch fails every proof, so it is a configuration invariant, not a runtime negotiation. - Self-healing reopens an App-Attest-gated registration race on each verifier loss. An attacker who knows the
roomNameand can pass App Attest could register a bogus verifier in the window before the phone re-registers, blocking reconnect. It is availability-only (Noise still authenticates the Mac and protects content), doubly gated (roomName secrecy plus App Attest), and recoverable by re-pairing. §11 lists a binding that would close it.
10. Migration / rollout plan
- Ship clients that understand both pairing modes: direct (
relay=,v=1) and resolved (resolver=,v=2). Mode is selected by which parameter is present. - Existing beta users keep working — their
v=1relay=QRs are unchanged and still connect directly. No re-pairing required. - Self-hosting stays on direct mode — a user running one relay emits
relay=; nothing else needed. (A self-hoster wanting sharding can run their own resolver and emitv=2.) - Introduce the shard map as a static CDN file plus a resolver URL; the managed service emits
v=2resolver=QRs for new pairings. Bootstrap the map with today's single box as the sole entry (ranges: [{0..N_BUCKETS-1 → current host}]). - Add
bufferutil, disablepermessage-deflate, add backpressure handling in the relay (independent of mode; immediate throughput win). - Stand up a second box, publish a two-range map, exercise a reshard end-to-end on beta traffic.
- Add host-side reject-on-doubt and shard-map polling; validate the reconnect/retry path and the split window under a deliberate bucket move. Add the phone's self-healing re-register fallback (§6.7) and validate it by moving an established room's bucket to a fresh host.
- Grow by adding boxes and republishing the map as real adoption numbers land.
11. Open questions / future work
- Geo-aware placement. Sharding on
roomNameis deliberately geography-blind — the hash picks the bucket and the bucket picks the host, with no notion of where the user is. Honoring latency would require a level above the bucket map: separate per-geo clusters, with the client selecting its cluster (e.g. GeoDNS on the resolver URL, or a region tag added to the QR) and each cluster running its own independent shard map. Deferred — not needed while the fleet is small; revisit only if latency complaints appear. - 10 Gbps boxes. If one Node process can't fill the port, run multiple workers and reintroduce intra-box routing (per-worker port encoded in the descriptor, or an on-box HAProxy consistent-hashing the room key to a worker). Avoided entirely at gigabit with one process per box.
- Least-loaded placement. Initial bucket→host assignment is currently static/manual; a tiny coordinator or load-aware publisher could balance new buckets automatically. Keep it out of the media path.
- Sign the shard map. Not required today (§9): HTTPS from our domain plus the QR-anchored resolver already authenticate the map, and E2EE bounds a tampered map to DoS/metadata, not content. Adding a detached signature (public key pinned in both clients and relays, verified before adopt) would remove a TLS-terminating third-party CDN from the trust path. Revisit only in that configuration; it adds key management and a new signature-mismatch availability mode, so it is net-negative until then.
- Close the re-registration race by binding the address to the verifier. A post-establishment address
roomName' = SHA256(canonical("iterm2-room", [rs, pid, verifier]))would let the relay require any registeredV'to satisfyroomName' == H(rs, pid, V'), so a bogus verifier cannot match the address the devices actually use (§6.7). The cost is that establishment then moves the pairing to a different bucket (the address changes once the phone's verifier exists), an added complication. Ship the App-Attest-gated self-heal first and treat this as hardening only if the DoS window ever proves unacceptable. - Session survival across host restart. Not needed (stateless relay); revisit only if reconnect glitches during planned restarts become a complaint.
- P2P. Intentionally out of scope, but the largest possible lever on relay bandwidth if it's ever needed — would remove whatever fraction of connections can go direct from the relay entirely.
Appendix A: Key invariants (don't violate these)
N_BUCKETSis immutable (fixed at 65536 = low 16 bits ofroomName). Changing it rehashes every pairing. The bit-extraction convention must be byte-for-byte identical across Swift and Node — ship a test vector.A host newly admits a bucket only if its own current map says it owns it (reject-on-doubt). Relinquished buckets are not newly admitted; their existing rooms are drained gradually at
RESHARD_EVICTION_RATE(§7.4), never dropped en masse.Everyone ignores map versions older than the newest they've seen. (Monotonicity.)
Old owner relinquishes before new owner accepts (break-before-make) — or accept a brief self-healing split with single-write publish. Never make-before-break.
The relay holds no durable per-pairing state that must survive a host change. The join verifier is soft state (
verifier = HKDF(roomSecret), re-derivable by both endpoints and re-registered on demand; see §6.7); the assertion counter and per-room byte quota are host-local and reset safely on a move. This is what makes a host's home reassignable at will and a dead host's rooms recoverable with nothing to migrate.Map edits preserve existing boundaries; re-equalization is a deliberate event. The bucket space is a ring; a host owns one or more contiguous arcs (§6.3). Adding a host carves its arc out of existing ones and removing a host merges its arc into a neighbor, so a membership change moves only ~
1/Kof buckets (§7.5). Only a planned, higher-churn rebalance may move boundaries wholesale to even out drift.A managed box's provisioned hostname is one string used three ways, and all three must be byte-identical: its TLS certificate SAN, its shard-map
hostentries (which buckets it owns), and its proof origin ("https://" + hostname). The admission and delete transcripts and the App Attest clientData all bind that origin (§6.10). A mismatched byte does not fail the connection; it silently fails every signed join for that box. The client derives the same origin as"https://" + hostfrom the map verbatim (resolved mode) or from the QRrelay=value (direct mode), so the maphostmust stay in one canonical form (lowercase ASCII, no trailing dot, port only if non-443).
Appendix B: Pairing URL formats
Direct mode (v=1) — existing beta users and self-hosted single relays:
iterm2://pair?v=1&proto=Noise_XK_25519_ChaChaPoly_BLAKE2s&rs=<mac static pubkey>&pid=<pairing ID>&relay=<relay URL>The client connects directly to relay. No shard map or resolver is involved.
Resolved mode (v=2) — the managed, sharded fleet (or a self-hoster running their own resolver):
iterm2://pair?v=2&proto=Noise_XK_25519_ChaChaPoly_BLAKE2s&rs=<mac static pubkey>&pid=<pairing ID>&resolver=<resolver URL>The client computes roomName via the RelayRoom derivation (§6.2), then bucket = uint16be(roomName[30], roomName[31]) (N_BUCKETS = 65536), fetches the shard map from resolver, looks up the owning host, and connects there, with the caching, reject-on-doubt, and monotonic-version behavior of §6 and the self-healing reconnect of §6.7.
proto, rs, and pid are identical across modes; the only differences are relay= (direct) vs resolver= (resolved) and the version tag. Clients select the mode by which parameter is present, so the two can coexist indefinitely.
Appendix C: Tunable parameters and default values
Starting values for the managed fleet. All are operator-tunable; the relationships between them (not the absolute numbers) are what the correctness arguments in §6-§7 depend on.
| Parameter | Default | Role |
|---|---|---|
SHARDMAP_TTL (CDN Cache-Control: max-age) |
5 s | Bounds how stale a CDN edge copy can be. Kept below the poll interval so a poll usually revalidates to a fresh copy (§6.3). |
SHARDMAP_POLL_INTERVAL |
10 s | How often each relay (and a client's background refresh) re-fetches shardmap.json. Sets how fast a reshard lands (§6.8). |
RESHARD_DRAIN_DELAY |
20 s (= 2 × poll) | How long a relinquishing host waits after seeing the new map before it starts evicting, covering worst-case poll-phase skew between two hosts plus TTL/propagation margin (§7.4). |
RESHARD_EVICTION_RATE |
10 rooms/s | Rate a relinquishing host closes live rooms, capping the reconnect/handshake load a move imposes on the new owners (§7.4). |
RECONNECT_JITTER_INITIAL |
uniform [0, 3 s] | First reconnect delay after a 4421 evict, a 421 reject, or a dropped socket; full jitter, flattens a whole-host reconnect storm (§7.3). |
RECONNECT_BACKOFF_BASE / RECONNECT_BACKOFF_CAP |
1 s / 30 s | Full-jitter exponential backoff on repeated failure: delay = uniform(0, min(CAP, BASE · 2^n)), n = failed attempts (§7.3). |
Relationships that must hold (the numbers may change together, but these orderings must not break):
- TTL < poll interval. A poll should usually see a fresh edge copy (5 s < 10 s).
RESHARD_DRAIN_DELAY≥ 2 × poll interval. One interval covers the worst-case poll-phase skew between the gaining and losing hosts reacting to the same publish; the second is margin for TTL, CDN propagation, and poll jitter (§7.4). This is the one relationship the drain-safety argument depends on, so it is expressed as a multiple of the poll interval, not a fixed 20 s.- Convergence time of a bucket move ≈
RESHARD_DRAIN_DELAY+ rooms /RESHARD_EVICTION_RATE. At the defaults, a ~300-room host drains in roughly 20 s + 30 s ≈ 50 s. Reshards are not urgent (§7.4), so this latency is a feature (it paces handshakes), not a cost. - Eviction rate paces the graceful case; jitter paces the ungraceful case. A planned reshard's reconnect load is bounded by
RESHARD_EVICTION_RATE; only an unplanned host death drops every room at once, and there the reconnect jitter (§7.3) is what flattens the storm.
Localization (`docs/localization.md`)
Strings and localization (developer guide)
How user-visible text works in iTerm2: how to add a localizable string, what to
leave in English, and the rules that keep translations correct. For translating
the app into a new language see docs/adding-a-translation.md; for the Info.plist
mechanism see docs/info-plist-localization.md.
Overview
iTerm2 localizes with Apple String Catalogs (.xcstrings). The source
language is English; any untranslated string falls back to English, so
partial translations are safe. There are three kinds of catalog:
- Code strings -
xcstrings/Localizable.xcstrings, keyed by symbolic keys
(Feature.Name). This is what you edit when writing code. The build copies
it tosources/Localizable.xcstrings, which is gitignored; never edit that copy. - XIB strings - one
mul.lproj/<name>.xcstringsper XIB, keyed by object ID.
Populated automatically by the build's extraction step; you don't hand-write
these. - Info.plist strings -
sources/InfoPlist.xcstrings(see the Info.plist doc).
Adding a user-visible string
Wrap the English in a localized-string call with a stable symbolic key, an
English default value, and a comment that explains the context and every
placeholder. The key convention is Feature.ShortName.
- Swift:swift
String(localized: "PseudoTerminal.CloseTabsHeading", defaultValue: "Close Tabs", comment: "Warning heading shown when closing multiple tabs") - Objective-C:objc
NSLocalizedStringWithDefaultValue(@"PseudoTerminal.CloseTabsHeading", nil, [NSBundle mainBundle], @"Close Tabs", @"Warning heading shown when closing multiple tabs")
The comment is the only context a translator gets - write it. Use[iTermUserDefaults userDefaults]-style project conventions elsewhere, but for
strings always go through these calls so extraction picks them up.
What to leave in English
Not every string should be localized. Mark such sites with a// Localization unneeded comment (so a future reader knows it was considered)
and leave them as plain literals - they then never reach the catalog. Leave
English:
- Stable identifiers and sentinels - anything compared with
==, used as a
dictionary key, or persisted (aNoSync…user-defaults key, a CodablerawValue, a generated profile name that is looked up by that exact string).
Localizing these forks identity across locales. If such a value is also
displayed, keep the sentinel English and localize only at the display site. - Technical/debug output -
DLog/RLogand format skeletons. (Script-console
diagnostics are localized, per commit c7f3e2d24, since users read them.) - Prompts sent to an AI model (system/instruction prompts). These are tuned
engineering artifacts; English maximizes instruction-following, and the user's
language reaches the model through their query. Localize UI about the AI, not
the instructions to it. - Scripting-API error reasons. The
errorReasonreturned through the Python
scripting API (ITMInvokeFunctionResponse.error.errorReason, surfaced asRPCExceptionmessages) is shown to the user (in the Script Console and in
error dialogs), so it should be localized. The built-in-function,
function-call, and expression-parser error reasons are therefore localized like
any other user-facing string. Do not add matching against these reason strings to
tests or scripts; match on error codes/domains instead, which stay stable across
locales.
Write complete sentences, not injected fragments
Do not build a sentence by injecting a translated word or phrase into a
format frame:
// WRONG: "Clear"/"Reset" is a translated verb spliced into a frame.
String(localized: "…", defaultValue: "\(verb) all sessions?")Word order, gender, case, and agreement differ by language, and a translator sees
the frame and the fragment separately, so the result is ungrammatical elsewhere.
Instead give each case its own complete localized sentence (one string per
verb/kind/state). Injecting self-contained values - names, counts, file
paths, hostnames, error text - is fine, because the value carries no grammar of
its own.
When a completed sentence still interpolates more than one value, use
positional specifiers (%1$@, %2$@) so translations can reorder them:
"Plugin has version %1$@ but iTerm2 expects %2$@."A bare %@ binds to the next argument in order and cannot be reordered; a
positional %2$@ can move ahead of %1$@.
Format specifiers
%@/%1$@- a string;%ld/%lld/%lu/%d- an integer;%%- a literal percent.- Keep count and type consistent with the English; never change
%ldto%@. String(localized:)and+[NSString localizedStringWithFormat:]are
locale-aware and apply the locale's digit grouping to integer specifiers
("10,001"), so you rarely need aNumberFormatter.- Extraction quirk:
extractLocStringsnormalizes multi-specifier strings to
positional form and misreads a literal%word(e.g. tmux's%begin) as a
specifier. Move such literals out of the localized string and pass them as
arguments;tools/extract_strings.shhas a guard that fails the build on a
malformed positional specifier.
Plurals
Wording that depends on a count ("1 file" vs "3 files") needs a String Catalog
plural, not a count == 1 ? … : … ternary - a ternary bakes in English's two
number forms, and other languages have more categories (CLDR: Chinese has one,
English two, Russian four, Arabic six).
Source side - pass the count as an argument:
- Swift:
String(localized: "Key", defaultValue: "… \(count) …", comment: …) - ObjC:
[NSString localizedStringWithFormat:NSLocalizedStringWithDefaultValue(@"Key", nil, [NSBundle mainBundle], @"… %ld …", @"…"), (long)count]- you must use
localizedStringWithFormat:, notstringWithFormat:, or the
plural rule is never applied.
- you must use
Keep the plural string to the count argument alone; append lists, names, or
other values outside it so the catalog entry stays a simple single-argument
plural rather than a multi-argument substitution.
Catalog side - after the key is extracted into Localizable.xcstrings,
replace its en stringUnit with a variations.plural block holding one andother stringUnits (state new) by editing the raw JSON - not through Xcode's
String Catalog editor - then re-run make extract-strings to renormalize (see
"extract_strings.sh is the only canonical way to write the catalog").xcstringstool sync is a key-by-key merge,
not a regenerate, so it preserves those variations (and translations) across
future extractions. Verify with:
xcrun xcstringstool compile --output-directory <dir> xcstrings/Localizable.xcstringsThe key's compiled en.lproj/Localizable.stringsdict entry should haveNSStringFormatSpecTypeKey = NSStringPluralRuleType.
Preservation is conditional - any of these silently un-pluralizes the site:
- Renaming the key in code - the old plural entry goes stale and a fresh flat
key appears, so a rename means re-authoring the plural. - Dropping the key's code reference - it goes stale and is eventually pruned.
- Changing the English
defaultValuewithout hand-updating the variations -
sync can't tell which plural category the flat source maps to, so keep theothervalue identical to thedefaultValue.
Plurals that also carry a value (substitutions)
When a string pluralizes on a count and interpolates another value (a path, a
name), use a substitution: a top-level value like "%2$#@children@" plus asubstitutions block whose argNum/formatSpecifier point at the count and
whose plural variations spell out the categories. Inside a variation value,%arg refers to that substitution's own number; you may also reference other
arguments positionally. Prefer restructuring to a single-argument plural when you
can (append the extra value outside the plural string); reach for a substitution
only when the value sits mid-sentence.
Worked references: ScriptsMenu.BumpUpgradeIncompleteSentence (ObjC %ld) andNonTextPaste.PasteManyFilesPrompt (Swift %lld) for simple plurals;ApplyLayout.SplitterTooFewChildren and Workgroup.ExitOneWorkgroupCloseSessions
for substitutions. A // TODO(l10n): plural marker flags a site still using the
ternary anti-pattern.
Curly quotes and punctuation
In user-visible English use curly quotation marks (“ ”, ‘ ’), never straight
quotes - " only as an inch shorthand. Do not "fix" existing curly quotes to
straight ones. Translators use their own language's marks.
How strings reach the catalog
Swift emits .stringsdata during the build (SWIFT_EMIT_LOC_STRINGS=YES); ObjC
is scanned by extractLocStrings. tools/extract_strings.sh (run by the Beta and
Deployment targets, and standalone via make extract-strings) merges both intoxcstrings/Localizable.xcstrings with xcstringstool sync. So a new code string
appears in the catalog after an extraction, not immediately.
`extract_strings.sh` is the only canonical way to write the catalog
tools/extract_strings.sh (i.e. make extract-strings) owns the exact
serialization of xcstrings/Localizable.xcstrings: key ordering, Unicode escaping,
and whitespace. Treat its output as the canonical form and regenerate the catalog
only through it.
Do not let anything else rewrite the file. In particular, openingLocalizable.xcstrings in Xcode's String Catalog editor (or round-tripping it
through any other tool) reserializes the whole catalog in a different order, so
even a one-string change surfaces as a whole-file diff (tens of thousands of
lines) that is unreviewable and collides with every other catalog change in
flight. If you ever see such a diff, do not hand-reconcile the noise: runmake extract-strings and commit that as the new baseline. A catalog last
written by some other tool will otherwise make the next extraction churn the
entire file right back.
Manual JSON edits are occasionally required (the plural variations andsubstitutions blocks below). Make them as small raw-text edits to the JSON, not
through the catalog editor, then run make extract-strings once to renormalize:xcstringstool sync preserves your variations and existing translations while
restoring the canonical serialization.
Testing a localization
Build, then launch forced into a language:
open Build/Development/iTerm2.app --args -AppleLanguages '(zh-Hans)'Check the menu bar, Settings (Cmd-,), and a few alerts (with different counts to
exercise plurals). Menu actions are matched by stable identifiers, not titles, so
translating titles is safe.
Names And Titles (`docs/names-and-titles.md`)
Names and Titles in iTerm2
This document explains how names and titles work in iTerm2, including profiles, sessions, tabs, and windows.
Overview
iTerm2 has multiple overlapping naming systems:
| Concept | Where It Appears | Primary Source |
|---|---|---|
| Profile Name | Profiles panel, menus | User-defined in preferences |
| Session Name | Session title view, tab (default) | Computed from variables |
| Tab Title | Tab bar | Session name or override |
| Window Title | macOS title bar | Active session or override |
1. Profile Name
The simplest concept. Stored in the profile dictionary under KEY_NAME ("Name").
Settings UI: Settings > Profiles > General > "Name" (text field at top of panel)
- User-facing identifier in Profiles preferences
- Default value for
session.profileNamevariable - Used as a fallback in title computation
- Stored with profile GUID in
ProfileModel
Key files: ITAddressBookMgr.h, ProfileModel.m
2. Session Name
The most complex concept. Session name is computed by iTermSessionNameController and exposed as presentationSessionTitle.
Variables Involved
| Variable | Description |
|---|---|
session.name |
The presentation name (what's displayed) |
session.autoNameFormat |
Swifty string format for automatic naming |
session.autoName |
Evaluated result of autoNameFormat |
session.profileName |
Name of the current profile |
session.iconName |
Set by OSC 1 escape sequence |
session.windowName |
Set by OSC 0/2 escape sequences |
autoNameFormat and autoName
autoNameFormat is a "swifty string" (interpolated string with variable substitution) that determines the session's automatic name:
Default: profile name
Then: most recent of (manually-set name, icon name from escape sequence)The evaluated result becomes autoName. Cycle detection prevents infinite loops (displays "[Cycle detected]").
Title Components
Profile setting KEY_TITLE_COMPONENTS is a bitfield (iTermTitleComponents) selecting what to include.
Settings UI: Settings > Profiles > General > "Title:" (dropdown menu)
The dropdown shows combinations of these components:
iTermTitleComponentsSessionName- Auto-generated session nameiTermTitleComponentsJob- Current foreground jobiTermTitleComponentsWorkingDirectory- Current directoryiTermTitleComponentsTTY- TTY deviceiTermTitleComponentsProfileName- Profile nameiTermTitleComponentsProfileAndSessionName- BothiTermTitleComponentsUser- UsernameiTermTitleComponentsHost- HostnameiTermTitleComponentsCommandLine- Full command lineiTermTitleComponentsSize- Terminal dimensionsiTermTitleComponentsCustom- Custom string (mutually exclusive)
Custom title functions registered via the iTerm2 Python API also appear in this dropdown.
Title Providers
Sessions can use:
- Built-in title provider (default): Uses
iterm2.private.session_title()function - Custom title provider: Script-provided via iTerm2 API (
KEY_TITLE_FUNC)
Settings UI: Custom title providers appear in the Settings > Profiles > General > "Title:" dropdown alongside the built-in component options. When a Python script registers a title provider, it shows up as a selectable option.
Key files: iTermSessionNameController.m, iTermSessionTitleBuiltInFunction.m, iTermVariableScope+Session.m
3. Tab Title (aka "Icon Title")
What appears in the tab bar. Managed by PTYTab.
Priority Order
titleOverride(if set) - a swifty string- Active session's
presentationSessionTitle - For tmux:
tmuxWindowNameis also available
Tab Title Variables
| Variable | Description |
|---|---|
tab.title |
Computed effective tab title |
tab.titleOverride |
Direct override string |
tab.titleOverrideFormat |
Format for the override |
tab.tmuxWindowName |
Tmux window name |
Profile Settings
Settings UI: Settings > Profiles > General > "Tab Title:" (text field)
KEY_CUSTOM_TAB_TITLE- Custom tab title (evaluated as swifty string)- Placeholder text: "Tab Title (Interpolated String)"
- Supports variable interpolation like
\(session.name)or\(session.path)
Key files: PTYTab.h, PTYTab.m
4. Window Title
What appears in the macOS window title bar. Managed by PseudoTerminal.
Settings UI: Settings > Profiles > General > "Window Title:" (text field)
KEY_CUSTOM_WINDOW_TITLE- Custom window title (evaluated as swifty string)- Placeholder text: "Interpolated string. If empty, use tab title."
- If left empty, the tab title is used for the window title
Composition
The window title comes from the active session's windowTitle property, which returns _nameController.presentationWindowTitle.
Priority:
- OSC 0/2 window title (if set by escape sequence)
- Custom title provider result
- Built-in title computation
- Fallback: "Unnamed"
Window Title Variables
| Variable | Description |
|---|---|
window.title |
Computed window title |
window.titleOverride |
Direct override |
window.titleOverrideFormat |
Format for override |
Title Stacks
Window and icon titles support push/pop stacks (standard xterm behavior):
- pushWindowTitle // Save current title
- popWindowTitle // Restore saved title
- pushIconTitle
- popIconTitleThese are used by escape sequences for temporary title changes.
Key files: PseudoTerminal.m, iTermSessionNameController.m
5. Escape Sequences
OSC 0 / OSC 2 (Window and Icon Title)
Sets both icon name and window title. Stored in session.windowName.
ESC ] 0 ; title BEL // Set both window and icon title
ESC ] 2 ; title BEL // Set window titleValues are escaped with zero-width space (\u200B) to prevent injection.
OSC 1 (Icon Title Only)
Sets just the icon/tab title. Stored in session.iconName.
ESC ] 1 ; title BEL // Set icon title onlyOSC 81 (Pane Title - tmux)
Sets the pane title for tmux integration.
Key files: PTYSession.m, VT100ScreenDelegate.h
6. Tmux Integration
Tmux adds additional title concepts:
| Source | Description |
|---|---|
tmuxWindowName |
From #{window_name} - current process or rename-window result |
tmuxWindowTitle |
From #{T:set-titles-string} if set-titles on |
tmuxPaneTitle |
From #{pane_title} - set via select-pane -T |
Title Priority in Tmux Sessions
tmuxWindowTitle (if set) > windowName > tmuxWindowName > tmuxPaneTitleThe %window-renamed notification triggers updates when tmux renames a window.
Key files: TmuxController.h, iTermSessionTitleBuiltInFunction.m
Architecture
Class Responsibilities
PseudoTerminal (Window Controller)
└─ setWindowTitle: → updates macOS window title bar
│
└─ PTYSession.windowTitle
│
└─ iTermSessionNameController
├─ presentationWindowTitle (for window)
├─ presentationSessionTitle (for session/tab)
├─ _windowTitleStack (push/pop)
└─ _iconTitleStack (push/pop)
PTYTab
├─ titleOverride (if set, use this)
└─ activeSession.presentationSessionTitle (fallback)Variable Scope Chain
Session Scope
├─ session.name
├─ session.autoNameFormat
├─ session.autoName
├─ session.windowName (from OSC)
├─ session.iconName (from OSC)
└─ Tab Scope
├─ tab.title
├─ tab.titleOverride
└─ Window Scope
├─ window.title
└─ window.titleOverrideData Flow
- Profile created with
KEY_NAME - Session inherits profile, sets
session.profileName autoNameFormatdefaults to profile name- Escape sequences can set
windowName/iconName iTermSessionNameControllerevaluates title format- Computes
presentationSessionTitleandpresentationWindowTitle PTYTab.titleuses override or session's presentation titlePseudoTerminalupdates macOS window bar from active session
Settings UI Reference
All title-related profile settings are located in Settings > Profiles > General:
| Setting | UI Control | Key | Description |
|---|---|---|---|
| Name | Text field (top) | KEY_NAME |
Profile's display name |
| Title: | Dropdown | KEY_TITLE_COMPONENTS |
Components to include in session title |
| Title: | Dropdown | KEY_TITLE_FUNC |
Custom title provider (if registered) |
| Tab Title: | Text field | KEY_CUSTOM_TAB_TITLE |
Interpolated string for tab title |
| Window Title: | Text field | KEY_CUSTOM_WINDOW_TITLE |
Interpolated string for window title |
The Tab Title and Window Title fields accept "swifty strings" with variable interpolation:
\(session.name)- Session name\(session.path)- Current directory\(session.username)- Username\(session.hostname)- Hostname\(session.jobName)- Current job
Key Source Files
| File | Purpose |
|---|---|
iTermSessionNameController.m |
Central title computation and caching |
iTermSessionTitleBuiltInFunction.m |
Default title computation logic |
PTYSession.m |
Session model, title stack delegation |
PTYTab.m |
Tab model, titleOverride handling |
PseudoTerminal.m |
Window controller, macOS title bar |
ITAddressBookMgr.h |
Profile key definitions |
iTermVariables.h |
Variable key definitions |
iTermVariableScope+Session.m |
Session variable accessors |
TmuxController.h |
Tmux title integration |
Summary Diagram
/* Detailed source-code truncated for AI context efficiency. */Notes 3.7.0beta10 (`docs/notes-3.7.0beta10.txt`)
Version 3.7.0beta10 was built on August 16, 2026.
Changes since 3.7.0beta9:
This version requires macOS 13 (Ventura) or later.
macOS 12 is no longer supported. Sorry for the
inconvenience!
New Features:
- Tab groups. Select tabs and gather them into a
named, colored group marked by a chip on the
tab bar. Groups stay contiguous, can be
collapsed and expanded with an animation, and
can be dragged as a unit within a window or out
to a new or existing window. A right-click chip
menu and a Firefox-style "Add Tab to Group" tab
menu let you rename, recolor, close, or ungroup.
Works on horizontal and vertical tab bars. - Kitty terminal drag-and-drop (OSC 72). Programs
that speak the protocol can accept files dropped
onto the terminal window and can start a drag of
their own content out to other apps, including
across an SSH connection when using it2ssh. - The left/right (vertical) tab bar can now be
made scrollable: turn on the new setting and
tabs that don't fit stay in the bar and are
reached by scrolling instead of the "More tabs"
overflow menu. This also works on horizontal
tab bars. - The uv-based Python runtime is now on by
default. Newly created, imported, and migrated
API scripts get a faster, self-managing
interpreter instead of the bundled Python
runtime. You can still override this in Advanced
Settings, and already-provisioned scripts are
unaffected.
Improvements:
- A new per-profile "Use ANSI colors for marks"
setting (Profiles > Colors, off by default)
draws command-prompt mark indicators using the
color preset's ANSI blue, red, and yellow so
they harmonize with your theme. - The Advanced Paste indicator now has a keyboard
toggle so you can switch a running paste between
waiting for the shell prompt and sending
keystrokes straight through, which lets you
answer a password or other non-standard prompt
mid-paste. Issue 5749. - When a keystroke with a modifier matches no key
binding by character but does match one for the
same physical key (for example after switching
keyboard layout or when an input method changes
the character a key reports), iTerm2 now offers
to turn on "Interpret key bindings based on
physical key". - The confirmation for closing a workgroup session
now warns that the rest of the workgroup, its
peers and their tabs, will close too. - The ConEmu progress protocol's paused state
(OSC 9;4;4) is now honored even when the
percentage is omitted. Issue 723.
Bug Fixes:
- Restoring a session whose saved working
directory no longer exists (for example a
network drive that is not mounted at launch) no
longer fails to relaunch the shell and silently
closes the tab. Issue 12955. - Decomposed Hangul jamo are now recomposed into a
single cell instead of being split into separate
cells. Issue 3063. - The 16th parameter of an SGR trigger is no
longer dropped by an off-by-one in the parameter
store. Issue 722. - Toolbelt list tools (Command History, Recent
Directories, Captured Output) no longer draw
their rows at zero width when a window opens
with the toolbelt showing. Issue 12964. - On macOS Tahoe dark themes, selected left/right
tab chrome is drawn at the tab's real height
again instead of a fixed cap-image height, so a
custom tab bar height looks right. Issue 715. - The top-right session indicators no longer smear
while swiping between tabs. - Fixed a window leak from the frame
canonicalizer. - A workgroup no longer shows a blank pane when it
tears down while a non-main peer is on screen,
and a peer host's toolbar now refreshes after
its nested port is wired. - Fixed word-boundary punctuation typos in the
default settings. Issue 720. - Fixed two format-string bugs in logging calls.
Issue 712. - Fixed various crashes.
Notes 3.7.0beta2 (`docs/notes-3.7.0beta2.txt`)
Version 3.7.0beta2 was built on May 5, 2026.
Changes since 3.7.0beta1:
Major New Features:
- Claude Code integration is now functional (beta1
shipped only an onboarding stub). It’s built on
a new feature called Workgroups: a workgroup
lets a small group of related sessions live in
the same pane and switch between each other via
a per-session toolbar (or open as splits or tabs
instead). The Claude Code workgroup pairs a chat
session with a Code Review session for reviewing
Claude’s changes. Configure workgroups in
Settings > Shortcuts > Workgroups; still a work
in progress. - The new Clippings side panel attaches a list of
context items to a session, shown alongside the
terminal. The Claude Code integration uses it
for Code Review findings, command output
snippets, and similar context. Items render as
Markdown; press Space or click the magnifying
glass for a Quick Look-style preview.
New Features:
- An AI chat that’s linked to a session can be
docked into that session’s window. Use the chat
menu’s “Put Chat in Linked Session” item; toggle
visibility with View > Show Inline Chat. - New Python API App.async_apply_layout reshapes
one or more tabs in a single transaction: swap
panes, restructure splits, move sessions across
tabs and windows. - New “dual-mode” SGR colors let programs emit
different fg/bg for light vs dark mode so
scrollback stays readable across appearance
toggles. Use CSI 38:12:Rl:Gl:Bl:Rd:Gd:Bd m for
24-bit and CSI 38:13:Nl:Nd m for 256-color (and
48 for backgrounds).
Improvements:
- The it2 command-line tool can post a clipping to
the Clippings panel viait2 add-clipping. - New global variable iterm2.appBundlePath holds
the path to the running iTerm2.app for use in
interpolated strings. - The Copy Smart Selection Action accepts an
interpolated-string parameter so the copied text
can be derived from the match (for example, a
capture group) instead of always being the whole
match. Also fixes Copy from the right-click
context menu being a no-op. - Relative timestamps now use Go-style suffixes
such as +1m23s and +2h5m, giving sub-minute
precision in the 1m–1h range. - Background images can rotate through a folder of
images at a configurable interval. Pick a folder
instead of a file in Settings > Profiles >
Window > Background. - Settings > Profiles > Text > Special Exceptions
has a new Add Preset… button that inserts
entries for common Unicode blocks (Arabic,
Cyrillic, Greek, Han, Hangul, Hiragana/Katakana,
Private Use Area). You still pick the font. - iTerm2 now overrides the Se terminfo capability
so Neovim 0.12+ restores your profile’s default
cursor on exit instead of a block cursor. - Improved energy efficiency, particularly on
Apple Silicon.
Bug Fixes:
- Fixed various hangs that could cause a beachball
(closing the Settings window, changing tab
style, slow or wedged opendirectoryd lookups). - The git status bar no longer times out silently
in large repositories such as Chromium. When a
poll does time out, the bar shows “⚠ timed out”;
click it to double the timeout and retry. - Fix bell and other user notifications not being
posted when iTerm2 is fully covered by another
app. - Fix the cursor jumping to the end while typing
in the middle of an annotation. - Fix long custom commands failing immediately
with a zsh “unmatched '” error. - Fix shell escaping of embedded apostrophes and
CR/LF in single-quote (POSIX) and $'…' (bash)
modes. - Fix the session’s hostname variable going stale
when the system’s initial hostname (e.g. a
transient DHCP-provided name) differs from the
hostname the shell later reports. - Fix “Save Current Window to File with Contents…”
producing a file with no contents. - Fix tmux window shrink on first attach when a
pre-tmux session was buried. - Quarantined .iterm2arrangement files (downloaded
from the internet) now require explicit
confirmation before opening, with a summary of
embedded risk factors (custom commands,
triggers, SSH targets, Smart Selection Actions,
etc.). Reported by @naxus-audit. - Extend the state-restoration database integrity
check timeout to 120s after an unclean shutdown
so the “taking a long time” prompt doesn’t
appear on every post-crash launch. - The password-manager CLI picker now accepts
plain files namedbwand thebwsymlink
target (e.g.bw.js), which fixes selecting
Bitwarden’sbwon machines where Homebrew
installs it as a Node.js script. - Fix carriage return (\r) handling in the tmux
integration’s escape-sequence decoder. - Fix old (deleted) iTerm installs being detected
as the default handler for a URL scheme. - ⌥Y and the Character Viewer no longer insert a
backslash instead of ¥ on non-JIS keyboards. ⌥+¥
on JIS keyboards now sends ESC+\ again. - Fix positioning of the “Delete sends ^H” control
in profile key settings. - Fix the session title popup being too narrow in
the Edit Session panel. - The Always/Never choice in the “restore
tab/window title when an ssh session ends”
prompt is now broadcast to other sessions
showing the same prompt. - Fix orphaned quotes appearing in the Jobs
popover when process names contain shell-special
characters (e.g.package@version). - Fix Smart Selection Rule edits not flushing the
profile or updating running sessions until
Settings was reopened. - Fix a ghost divider painted inside a tool after
a manual toolbelt splitter resize. - Fix a stray en-space being appended to the git
status bar when it shows rich stats. - Fix various crashes.
- Fix various memory leaks.
Notes 3.7.0beta3 (`docs/notes-3.7.0beta3.txt`)
Version 3.7.0beta3 was built on June 2, 2026.
New Features:
- Tabs can now be placed on the right side
of the window.
Improvements:
- Undo Close now has the ⌘⇧T shortcut. Show
Tabs in Fullscreen moved to ⌘⇧U. - Sending or copying clippings now prefixes
each one with its title in bold Markdown and
separates multiple clippings with a
configurable string (default is a line of
two dashes). See the Clipping Separator
advanced setting. - In a workspace session of type code review, the
system prompt is now editable in Settings >
General > AI > Prompts. It defaults to the
bundled prompt and is passed to Claude Code at
launch. - Clippings now keep history. An archive
button in the clippings panel snapshots
the current list and clears it; back and
forward buttons walk the history. The
newit2 session archive-clippings
command and matchingiterm2.archive_ clippingsAPI call do the same. Claude
Code reviews now automatically archive
before each pass, so each review starts
with a clean list while old findings
remain reachable. - The clippings and inline AI chat panels
can now be resized by dragging their left
edge. The chosen width is remembered per
machine. - Closing a pinned tab now requires
confirmation, whether via the close
button, middle-click, ⌘W, or quitting
the app. The dialog identifies the tab
as pinned so the prompt is clear. - The Cycle Tabs Forward and Cycle Tabs
Backward key actions now have a help
button explaining their tap-vs-hold
behavior. - Add DeepSeek v4-flash and v4-pro models.
- DeepSeek v4 thinking mode is now supported.
Toggle the lightbulb in the chat toolbar to
enable. Reasoning summaries appear live in
the conversation and round-trip correctly
on multi-turn tool calls (fixes a 400 from
the API when reasoning_content was missing). - The “Configure AI Model Manually…” popover
now has a section for custom HTTP headers
that are appended to every AI request (chat,
vector store, and file upload). Useful for
proxy auth or routing tags. Custom headers
override built-in headers with the same
name. Values are stored unencrypted in
iTerm2’s preferences. - PDF chat attachments now work on Anthropic.
Previously the bytes were mangled into a
UTF-8 approximation; PDFs are now sent as
native document content blocks. The chat
attachment picker also disables files the
current AI provider cannot accept, and
rejected drag-and-drop attachments now show
an explanatory alert instead of vanishing
silently. - In ttmux, the per-pane modifyOtherKeys state is
now restored on attach (using tmux 3.5+’s
pane_key_mode format), so reattaching while nvim
or another app is running keeps the correct key
reporting mode active. - Open Quickly now uses recency as a
tiebreaker, so the session list (whether
the query is empty or matches several
panes) is ordered most-recently-used
first across tabs and windows. The
current session is omitted from results
since it is never a useful jump target,
so Cmd-Shift-O followed by Enter now
jumps to the most-recently-used other
pane. A new preview side panel shows a
snapshot of the highlighted session.
Open Quickly can also be assigned a
system-wide hotkey in Settings > Keys
that works while another app is in
front, without activating iTerm2 until
you commit a selection.
Bug Fixes:
- iTerm2 no longer spuriously offers to reset
terminal state or restore the tab and window
title when the local hostname changes (for
example when mDNS renames your .local host)
or when you run sudo. Any two localhosts are
now treated as the same host. - AI chat no longer fails with a tool_use_id
error after an auto-approved command. The
tool call was dropped from saved history
while its result was kept; the prompt is now
repaired when reloaded, healing chats saved
by earlier versions too. - Session notes now keep their distance from
the top-right corner of a session when it
is resized, instead of from the bottom-left. - A tab no longer shows a stale or missing
session status (such as “waiting”) when a
pane carrying a status is closed, moved
between tabs, or reparented (for example by
entering or exiting a workgroup). The tab’s
aggregated status is now recomputed whenever
its set of sessions changes. - Full-screen programs that don’t use the
alternate screen buffer (such as Claude
Code) no longer destroy on-screen content
when, on startup, they move the cursor to
the top of the screen and repaint over it.
The visible screen is now scrolled into
history first. Requires shell integration. - The “taking a long time to check state
restoration” prompt no longer appears so
readily right after login, when the system
is busy and disk I/O is slow. The integrity
check timeout is now extended in the first
ten minutes after boot, the prompt's
wording is friendlier, and it defaults to
continuing rather than discarding windows.
Issue 11809. - Editing a color in a color well no longer
silently switches the picker from sRGB to
P3 (or back). The color space the well is
showing is now preserved across the edit
roundtrip. Issue 12720. - The Python API server now takes an
advisory lock on its socket directory at
startup. A second iTerm2 instance sharing
the same application-support directory
(e.g. two non-suited copies launched from
different .app bundles) will refuse to
start the API server instead of silently
unlinking and rebinding the original
instance’s socket. - Localhost detection no longer breaks when
your computer's hostname changes (for
example after switching networks). Whether
a session is on the local machine is now
recorded when the host is first reported,
rather than recomputed later by comparing
names. This fixes the git status bar, the
workgroup diff list, opening local files,
and uploads/downloads going stale after a
rename. - Pressing Enter to commit an IME
composition (e.g. Chinese, Japanese,
Korean) in the AI Chat input no longer
prematurely submits the unfinalized
candidate text. - Open Quickly now updates the MRU order
when you jump to a session, so repeated
Cmd-Shift-O Enter cycles between the two
most-recently-used panes instead of
drifting onto a sibling pane that
happened to be active in the destination
tab. Issue 12799. - Open Quickly recency now strictly follows
focus order. Jumping to a pane no longer
bumps the previously-active sibling in the
destination window, and structural changes
(closing a pane, splitting, restoring) in
background windows no longer reorder the
list, so Cmd-Shift-O Enter reliably
toggles between the last two focused
panes. Issue 12799. - OSC color overrides (foreground, background,
cursor, selection, palette, and the iTerm2
SetColors variants) now survive macOS Light
/ Dark appearance switches and toggles of
the separate light / dark colors profile
setting. The override is written to every
appearance variant of the profile key so
it persists until OSC 110/111 or a manual
reset clears it. - The Cockpit orchestrator’s send_text tool
now routes payloads containing control bytes
(ESC, Ctrl-C, etc.) as raw keystrokes instead
of bracketed pastes. The previous wrap turned
embedded control bytes into literal pasted
data, so e.g. sending ESC :q ENTER to a vim
session inserted “^[:q” as text instead of
quitting. Pure prompt-style text still goes
through as a paste, as Ink-based TUIs expect. - Showing the clippings panel in a window
zoomed to fit the display no longer grows
the window horizontally. The tab content
area absorbs the panel’s width instead,
mirroring the toolbelt’s behavior. - The Install Claude Code Integration intro is
now presented as a real sheet attached to the
installer panel, rather than a subview
overlay. Enter now correctly activates
Continue instead of the Install/Next button
it was visually covering. - Workgroup peer-switch and toolbar shortcuts
now fire no matter which view in the terminal
window has keyboard focus, including the Diff
peer’s Run Anyway button, the Code Review
prompt’s text view, and the clippings table.
They are now dispatched centrally instead of
relying on the focused view to forward them. - The Claude Code installer now reliably adds
Enter/Exit Workgroup triggers to divorced
sessions whose original profile was picked.
The previous pass matched on the dict’s
KEY_ORIGINAL_GUID, which could be stale
after an arrangement reload. The walk now
also consults each live or buried session’s
runtime originalProfile reference. - The Code Review prompt overlay no longer
drifts out of sync with light/dark mode. The
panel cached a CGColor snapshot of
windowBackgroundColor resolved against an
arbitrary NSAppearance.current, so its
background could disagree with its own text
view. It now uses the existing layer-backed
solid color view, which refreshes the color
in updateLayer under the correct drawing
appearance. - The heavy window border no longer leaves its
corners unbordered on low-opacity windows. The
corner-radius detector failed on transparent
windows (their interior is not opaque enough),
so the border fell back to a wrong radius and
the corner arc was clipped away. Detection now
works at any opacity, with a better per-OS
fallback radius. Issue 12882. - The heavy window border now stays flush with
the window edges instead of sometimes drawing
as a smaller inner rounded rectangle. Its view
could be left at a stale size and was not kept
in sync with the window. Issue 12882. - The legacy (non-GPU) renderer now invalidates
only the lines that actually changed, instead
of repainting the entire view on every cell
update. Heavy TUI animations (e.g. spinners
that rewrite a single line at high frequency)
use significantly less CPU on hosts where the
GPU renderer is disabled. Issue 12790. - Anthropic tool calls reloaded from a chat
transcript would fail with HTTP 400 because
the function output got serialized as plain
text instead of a tool_result block. The
parser stored the tool_use id only on the
inner FunctionCall while the serializer
looked on the outer wrapper, so the id was
lost on round-trip. Both fields are now
populated so persisted tool calls survive
the round-trip. - Anthropic requests no longer 400 when a
stray user message gets persisted between a
tool_use and its matching tool_result. The
request builder reorders so the tool_result
is always emitted immediately after the
tool_use, with any intervening messages
bumped to after it. - Fix a launch crash when a saved status bar
layout referenced an RPC-provided text
component whose configuration was missing
the registration request (for example, from
a partial profile restoration). The base64
decoder dereferenced a NULL pointer instead
of returning nil. Issue 12861. - The “Respect shortcuts from System Settings >
Keyboard > Keyboard Shortcuts > Keyboard”
option used subset matching on modifier keys,
so a system shortcut bound to Ctrl+Right (or
any other strict subset of an event’s
modifiers) would also intercept
Ctrl+Shift+Right and similar keystrokes.
Modifier comparison is now exact. Issue 12859. - When launched with -suite, orphan adoption
could no longer reattach to surviving
multi-server daemons after a state-database
loss. The application-support directory is
already isolated per suite, but daemon
sockets were also being named with the
suite in the filename, while the orphan
adopter (and the settings-export exclusion
list) still expected the plain
iterm2-daemon- prefix. The suite is now
carried only by the containing directory,
matching how the dotdir fallback already
worked. A transitional fallback accepts
the {suite}-daemon-N.socket form so betas
that wrote that name don't strand their
daemons across the upgrade. Issue 12860. - Fix a crash when a Python API client invoked
a function whose argument string contained
non-BMP characters (for example, emoji). The
expression tokenizer’s optional-path
recognizer indexed by Unicode scalar while
the surrounding tokenizer tracks UTF-16
positions, so the offset could run past the
scalar count and trap. Issue 12856. - When ESC #3, #4, or #6 (DECDHL/DECDWL) was
received mid-line, characters already on the
line were correctly doubled in size but the
cursor was not advanced to compensate, so
text printed afterward overwrote the right
half of the existing characters. The cursor
is now repositioned when the line’s width
class changes. - ssh:// URL handling and Bonjour-discovered
host profiles now find ssh through the user’s
login shell PATH, so Homebrew’s ssh (or any
ssh outside /usr/bin) is used. The advanced
setting SshSchemePath, when changed from its
default, still wins. Issue 3415. - The faint-text opacity slider in Profiles >
Colors now has a minimum of 0.1, and existing
profiles with a stored value below 0.1 are
clamped at runtime. A value of 0 caused SGR 2
text to render in exactly the background
color, making it invisible. The slider’s
tooltip was also incorrect (it described
minimum contrast); it now describes faint
text opacity. - The “open password manager automatically”
feature no longer triggers when a shell
script briefly disables terminal echo to
suppress the visible echo of an OSC report
response (e.g. it2getvar, it2profile -g).
ECHO must now stay off for at least 0.2
seconds — tunable via the
DetectPasswordInputDebounce advanced
setting — before iTerm2 treats it as a
password prompt. - Fix the Minimal-theme tab bar overlapping the
terminal in fullscreen when adding a second
tab with “Hide tab bar when there is only
one tab” enabled. The titlebar accessory
hosting the tab bar grew from zero to its
full height but the content view was not
re-laid out to leave room for it. Issue - The Touch ID for sudo offer no longer relies on
AppleScript admin elevation, which fails on
macOS 26 with EPERM when writing to
/etc/pam.d/. Accepting the offer now lets you
copy the install command or run it in a new
iTerm2 window where you can enter your sudo
password. - Fix a bug where suppressing a warning would
not stick across launches when prefs are
loaded from a custom folder with “Save
changes: Automatically.” The debounced save
could lose the race against app exit, so
the next launch overwrote the just-saved
suppression with the stale remote copy.
applicationWillTerminate now flushes
synchronously when Automatic mode is on. - Fix Ctrl-letter keystrokes appearing as
literal text (e.g., “0x4” for Ctrl-D) in
tmux -CC mode when the tmux server has
extended-keys enabled and an inner app
has turned on modifyOtherKeys. Control
bytes 0x00–0x1F are now sent through
send-keys with the -H flag on tmux
3.0a or newer, which bypasses tmux 3.5’s
silent literal-text fallthrough for
unmodified C0 bytes. Issue 12845. - Fix dragging a tab or split pane onto a
window with only one tab. The destination
window’s tab bar is hidden in that case, so
AppKit could not deliver drag events to it
and a brand-new window was created instead.
Tab bars are now temporarily shown for the
duration of any tab or session drag.
Issue 12846. - Extend dual-mode SGR to underline color
via CSI 58:12:Rl:Gl:Bl:Rd:Gd:Bd m and
CSI 58:13:Nl:Nd m. - Request the badge authorization option
from UNUserNotificationCenter so the
dock-tile bell-count badge actually
renders and the “Badge application
icon” toggle appears in System
Settings › Notifications › iTerm. - Hide a top-right status indicator while
the mouse is hovering an inline button
it overlaps, so the button stays
reachable. - Honor IT2_APP_PATH in the iterm2
Python library’s AppleScript cookie
request, matching the “it2” CLI. Lets
external scripts targeting a -suite
dev build get a cookie from the right
iTerm2 instance instead of whichever
one macOS routes to. Issue 12850. - Honor IT2_SUITE when checking for
disable-automation-auth in the iterm2
Python library, so external scripts
targeting a -suite instance no longer
silently fail with 401 when stock
iTerm2 has “Always Allow All Apps”
enabled. Issue 12864. - Add advanced settings to customize
the window border color for focused
and unfocused windows (requires
“Show border around windows” with
no title bar). - Add a per-profile “Contextual Alternates”
toggle to the font picker’s options menu,
for fonts that expose calt. Lets you keep
calt-based coding ligatures on for one
profile while turning off surprising
substitutions (e.g. Monaspace rendering
[] as a checkbox) on another. Issue 12847. - Add an Erase All Settings and Data
button in Settings > General >
Settings. It offers to export first,
then erases preferences, profiles,
saved sessions, scripts, and the
contents of ~/.iterm2 and the app
support folder, then quits
immediately. Issue 12852. - Add keyboard shortcut bindings to
workgroup toolbar items. Navigation
cluster (back, forward, reload) and
the standalone reload toolbar item
each support a per-action shortcut,
with defaults of ctrl-cmd-left,
ctrl-cmd-right, and cmd-R. Shortcuts
take precedence over profile key
bindings and main menu items but
defer to global key bindings. - Add an optional per-peer shortcut
for jumping to a peer in a workgroup.
Configurable in the workgroup
session settings; when unset, the
built-in opt-shift-cmd-number
fallback still works. Custom peer
shortcuts route through the same
pipeline as workgroup toolbar
shortcuts, so they beat profile
bindings and the main menu but
defer to global key bindings. - Fix vertical window resize being
silently rejected when the window
sits partly below the screen. - Coalesce pending re-evaluations of
interpolated strings. Previously,
every dependency change enqueued a
fresh dispatch_async on the main
queue, so a tmux -CC pane title that
changed many times per second could
pile millions of blocks onto the main
queue and exhaust memory. Issue - Built-in browser: hide our message
handlers and skip the page-world
Notification, geolocation, console,
and audio bridges inside Cloudflare
Turnstile, hCaptcha, and similar
challenge frames so the captcha
probe sees an unmodified Safari
surface. - Built-in browser geolocation: stop
silently dropping requests when
permission is not granted; the page
now receives a PERMISSION_DENIED
error instead of hanging. Push the
decision to the calling frame so
navigator.permissions.query reflects
reality, and route success and error
callbacks back to the originating
iframe instead of always the main
frame. Stop persisting system-level
denials (Location Services off, OS
prompt declined) as per-site
decisions; only an explicit Allow
or Block in the per-site dialog is
remembered. The same fix applies to
notification permissions. - Honor the option-modified “Quit and
Close All Windows” menu item. iTerm2
now samples the option modifier at
quit time and erases saved state when
the user picked the option-modified
menu item, so it actually closes all
windows on the next launch. Issue - Clear tab status when a session’s
program terminates (broken pipe or
tmux detach) so stale status text
and indicators no longer linger. - Fix a stall after large output bursts
when the main thread was reading every
line of the burst to announce it for
VoiceOver. Two changes: the LineBlock
metadata provider no longer allocates
a heap block per inner-loop iteration
(which was churning through the objc
associated-objects table on release),
and the new-output announcement is
capped to the most recent 256 lines so
giant floods stop overwhelming
VoiceOver’s speech queue. - Fix Session Status toolbelt rows
appearing in arbitrary order when
the toolbelt is first opened. The
initial population now sorts by
priority like every other update
path. - Add a Diff workgroup session mode
that defers running the configured
command until there are staged or
unstaged changes to show. The Claude
Code workgroup’s Diff peer uses it,
so the peer no longer launches
difftool on a clean tree. Existing
Claude Code workgroups are migrated
on first launch so the Diff peer
picks up the new mode automatically;
flip it back to Regular in Settings
if you prefer the old behavior. - Add a urlHandlerCommand advanced
setting. When set, clicking a URL
(including OSC 8 hyperlinks and
auto-detected URLs) runs it as a
/bin/sh -c task with the URL in
(url) and a background-open flag in
(background), instead of handing the
URL to the system. Its output goes to
the Script Console. Lets you route
links through your own script, e.g. to
open in the background or pick a
browser mode. Empty by default, so
normal URL opening is unchanged.
Issue 12878. - Script Console no longer jumps to the
top when a new entry arrives. The
script list keeps its scroll position
and selection, and a filtered log view
stays put as new lines come in. - Fix black, unreadable text in the
Script Console Logs tab in dark mode. - Session Stats now shows the correct
keyboard shortcut for workgroup peers
that have a custom peer-switch
shortcut configured, instead of always
showing the built-in ⌥⇧⌘ number. - Jobs toolbelt and the status bar job
popover gain a “Notify on Termination”
right-click option. Monitored jobs show
a bell next to their name, and when the
chosen process exits a modal alert names
the job and process ID. - The badge no longer hides behind the
clippings panel. It now right-aligns
at the inner edge of the right-gutter
panel area in both the legacy and GPU
renderers. - AI text-typing tools (send_text in
orchestration mode and
insert_text_at_cursor in
session-bound mode) now accept a
small backslash-escape vocabulary so
the agent can drive control keys.
Recognized: \ for a literal
backslash, \n, \r, \t, and \uXXXX
(four hex digits) for any Unicode
scalar. Set append_newline to false
for a standalone control key (e.g.
Escape) so iTerm2 doesn’t tack a
newline onto it. Lets the agent
send things
like for Ctrl-D, for
Ctrl-Z, for Ctrl-L, and
for Escape. - Implement start_session for the
orchestrator. The agent can now
spawn a new window, tab, or
vertical split via the existing
start_session tool. Every spawn
prompts for user approval inline
in the chat, with the placement
and command (if any) shown in the
prompt. Returns the new session’s
synthetic workgroup_id so the
agent can immediately drive the
new session with the other tools. - Dropping a file onto the AI chat
text field now offers to attach it
when the current backend supports
that file type, instead of always
inserting its path as text. The
choice can be remembered. - AI chat can now attach images to
OpenAI models (vision), and audio
to OpenAI audio models on the
chat-completions API. Attachment
support is now decided by modality
rather than a per-format allow
list: if a backend can carry an
image, PDF, audio, or video block,
you can attach it, and an
unsupported file is reported by the
model instead of being silently
refused. - Announcements now become narrower
when the clippings panel is visible
so they no longer overlap it. - Python API: GetPromptResponse now
has excluded_subranges, listing the
cell regions inside the command
range that the shell marked as
non-input via OSC 133;A;k=s|c|r
(PS2 prefixes, right-prompt cells).
Available on responses to
async_get_last_prompt and
async_get_prompt_by_id. Always
empty on PromptMonitor.PROMPT
notifications — fetch via the RPC
after a COMMAND_END notification
for the populated value. - OSC 133 aid= now tracks nested
shell-integration sessions: a local
shell that runs ssh into a remote
shell with shell integration (or a
REPL that emits its own A/B/C/D)
no longer confuses iTerm2's mark
bookkeeping when the inner D's
arrive before the outer's. Each
mark carries the aid from its
A/B/C/D markers and a parentAid
pointing at the open mark it
nested under. D;aid=X closes the
matching command instead of the
topmost. If an outer command dies
(ssh tunnel drop, etc.) without
the inner ever closing, the outer
D cascade-closes every still-open
mark whose parentAid chain leads
back to it so no inner mark leaks
as still-running.
Notes 3.7.0beta4 (`docs/notes-3.7.0beta4.txt`)
Version 3.7.0beta4 was built on June 18, 2026.
Changes since 3.7.0beta3:
Major New Features:
- Companion App: the new iOS app, iTerm2 Buddy,
is now functional. Pair your Mac with your
phone by scanning a QR code (iTerm2 >
Companion Device Settings). Devices connect
through a relay using end-to-end encryption, so
they work even when the Mac and phone are on
different networks, and end-to-end encryption
ensures your data is visible only to you. From
your phone you can interact with iTerm2 via AI
chat.
New Features:
- New Lock Layout menu item (in the Window
menu, next to Lock Size) that freezes a
window’s layout: it prevents splitting,
adding or closing tabs, reordering tabs,
closing or dragging panes, and moving panes
or tabs to other windows. Resizing panes,
opening new windows, and closing the window
still work. - A new Variable Changed trigger match type
fires when a named session variable changes
to a value matching an optional regex. - A process inspector popover was added to the
Jobs view (toolbelt and status bar popover).
Hover a row to see the full command as a
tooltip, or press space to open a popover
with the full command, process ID, start
time, working directory, environment
variables, and a list of open files and
network connections. - Chats in the AI chat list now get an icon
drawn by your AI model from the chat title.
Chats without one show a default icon. The
prompt can be edited in Settings > General >
AI > Prompts. - You can @-mention terminal sessions in
orchestration chats to bring them into the
conversation. - The Session Status tool can notify you when a
watched session or window changes status. The
alert offers a Reveal button that jumps to
the session, and the same control is now
available in the Cockpit and the Window menu. - The Session Status tool can merge workgroup
statuses, collapsing each workgroup into a
single row that shows its highest-priority
status. - The Python API’s create_tab can now create a
tab in the background. Set select_tab=false on
CreateTabRequest to add a tab without
selecting it or raising its window. - App.async_apply_layout can now create
brand-new sessions in place via new_session
leaves, instead of only rearranging existing
ones. - A new window.title variable holds the
rendered window title, alongside the existing
session.presentationName and tab.title.
Improvements:
- The default AI request timeout was raised from
30 seconds to 5 minutes. - The AI command safety check now uses your
configured AI model. It runs deterministic
terminal rules first and only asks the model
when those rules cannot decide. Because it can
send commands to your (possibly cloud)
provider, it is opt-in. Users who already
enabled it keep using free on-device Apple
Intelligence and are asked once whether to
switch to their model. - Auto-approved AI tool requests are now
persisted instead of squelched, so they
survive reloading a chat. - AI models were updated. Support for Claude
Opus 4 was removed and the latest models were
added; temperature is omitted for Opus 4.7
and newer. - Orchestration chats auto-approve safe session
spawns, render tool-call activity inline, and
show @-prefixed session and workgroup ids as
name links. AI chat now offers orchestration
when there is no session to link. - The Claude Code setup wizard has clearer
navigation and highlighting. - The code review overlay now defaults to the
last-used prompt when reloaded. - Workgroup peers are persisted and restored
across app restarts. The peer switcher spins
an activity indicator while a peer is working,
and the Workgroups settings pane gained a help
button. - The Cockpit can watch statusless sessions by
observing their screen, shows session detail
under the name when grouping by status, and no
longer dims when it loses key status. - The Settings window title now shows the suite
name when running a named instance. - The Git diff menu excludes untracked files
from its per-file statuses. - Automatic text substitutions are now disabled
in the AI chat input field. - Auto-launched API scripts no longer warn about
pycache directories, and context menu
items registered via the Python API are now
sorted by display name for a stable order.
Bug Fixes:
- A key bound to “Bypass Terminal” now reaches
macOS for function, arrow, and numeric-keypad
keys, so global shortcuts in other apps work
again. Issue 12884. - A live filter no longer drops content that
arrives while it is mid-search. - A tab no longer shows a stale or missing
session status when a pane carrying a status
is closed, moved between tabs, or reparented
(for example by entering or exiting a
workgroup). - Fold All and Clear Buffer no longer leave
portholes behind. - Copying selected text from a porthole now
works in apps that prefer rich text, and
clicking a porthole in an inactive split pane
now activates that pane. - Next Pane and Previous Pane work again while
maximized after restarting a session. - The grid and scroll view are now re-fit when
entering or leaving instant replay. - The Jobs toolbelt is now populated immediately
for restored sessions. - Installing or uninstalling the Claude Code
integration now persists its trigger changes,
so they are no longer lost on the next launch. - Fixed a threading issue in AI chat streaming.
- Fixed several low-risk crashes seen in 3.6.10
reports.
Notes 3.7.0beta5 (`docs/notes-3.7.0beta5.txt`)
Version 3.7.0beta5 was built on June 23, 2026.
Note: if you are beta testing the iPhone companion
app iTerm2 Buddy, you'll need to update it after
upgrading iTerm2 to this version.
Changes since 3.7.0beta4:
New Features:
- Blinking cursors can now fade smoothly in and
out instead of toggling abruptly. Turn on
“Smooth blink” in Settings > Profiles > Text,
where you can tune the fade-in and fade-out
durations, easing curves, and how long the
cursor stays fully visible and fully hidden
between fades. Off by default. - The status bar can now draw a separator line
between itself and the terminal content. Turn
it on in the status bar layout’s settings.
Improvements:
- The job name shown in titles, the session
icon, and automatic profile switching now
reflects the foreground job actually attached
to the terminal, hiding helper subprocesses
that run in the foreground process group but
are piped to their parent (for example an MCP
server spawned by claude, or caffeinate). The
raw deepest job is still available as the
deepestJob variable. Note that automatic
profile switching now matches the tty-attached
job, so a rule keyed on such a helper name
will no longer fire for it. - You can now choose where tmux windows created
outside iTerm2 open: a new window, the focused
window, or the topmost window already hosting
that tmux session. - Terminal-initiated downloads (it2dl) now ask
whether to save to the Downloads folder or to
choose a destination. The choice is
silenceable, so you can always save to
Downloads or always show a save panel.
Issue 12897. - Added Edit > Marks and Annotations > Annotate
Selection, so annotating a selection can now
be given a keyboard shortcut. Previously it
was only on the right-click menu. - In an AI orchestration chat, @-mentioning a
session or workgroup now grants the chat
standing permission to control it, so the
agent acts there without the one-time approval
prompt. A notice with a Revoke button lets you
take the permission back whenever you want. - Anthropic prompt caching now also covers the
conversation history, not just the system
prompt and tools, so long chats reread the
earlier turns from cache instead of paying
full input price every turn. - Right-click a row in the Session Status
toolbelt tool and choose Snooze to move it
below a divider at the bottom of the list
and dim it. A snoozed entry un-snoozes
automatically the next time its status
changes. - The inline chat panel now has a toolbar to
create a new chat, switch chats, open the
session/link menu, and hide the panel. New
inline chats ask whether to link to the
session or become an orchestrator instead
of linking automatically. - Chat lists now mark orchestrator chats with a
wand glyph. The inline chat switcher bolds chats
linked to the current session.
Bug Fixes:
- Fixed a bug where a pane could restore in your
home directory instead of its previous working
directory after restarting iTerm2. Sessions
that used automatic profile switching saved a
value that could not be read back, which
silently dropped the rest of the restored
session state. - SSH integration no longer leaves Ctrl+C unable
to interrupt programs on remote hosts whose
Python is older than 3.7 (e.g. CentOS 7,
AlmaLinux 8). The login shell handoff was
leaving signal generation disabled on the tty. - The heavy window border now has a uniform
width: its corners no longer look thinner than
its edges or pick up semi-transparent pixels.
It is now drawn entirely inside the window
instead of straddling the window edge.
Issue 12882. - The session-logging and archive folder pickers
now use the folder you select rather than its
parent when you don’t open it first. - Clicking a notification posted by a background
tab of a dedicated hotkey window now selects
the source tab and pane instead of leaving the
previously-selected tab in front. Issue 12902. - A full-screen program that does not use the
alternate screen (such as Claude Code) and
repaints from the top no longer overwrites a
fold above the cursor. The fold is scrolled
into history instead of being clobbered and
left with an orphaned unfold icon in the
margin. This works even without shell
integration installed. - AI chat with DeepSeek no longer fails with an
“insufficient tool messages” error after a
tool call that was interrupted or left
unapproved. - The AI orchestrator’s get_screen_contents tool
now marks faint text and the cursor position.
Inline shell suggestions and ghost completions
are drawn faint, so the agent used to mistake
a suggestion for a command you actually typed. - An AI orchestrator watcher waiting on a
session’s reported status no longer hangs
forever if that status gets stuck. After five
minutes without the expected change it falls
back to reading the screen to confirm the real
state. - OpenAI prompt caching is more reliable: the
Responses API request now sorts its JSON keys
so the cached prefix stays stable. - Restoring a window with an open inline AI chat
panel now reopens the panel on the chat it was
showing, instead of widening the terminal. - The companion device no longer triggers a
keychain confirmation prompt when it connects
or when a notification is sent. The paired
identity is now read once at launch, while you
are present, instead of mid-connection.
Notes 3.7.0beta6 (`docs/notes-3.7.0beta6.txt`)
Version 3.7.0beta6 was built on July 3, 2026.
Note: if you are beta testing the iPhone companion
app iTerm2 Buddy, you'll need to update it after
upgrading iTerm2 to this version.
Changes since 3.7.0beta5:
New Features:
- The iPhone companion app, iTerm2 Buddy, can now
stream to your phone as video, so you can watch
output live, scroll back through history, zoom,
and select, copy, and paste text, without
re-implementing the terminal on the phone. - Push notifications to iTerm2 Buddy now use
end-to-end encryption so Apple can't see
anything about them. - Companion app setup was made hopefully simpler.
- The Make Screenshot window has a new “Save As…”
button to choose where the image is saved,
alongside the existing Save button that writes
to your configured location.
Improvements:
- The iTerm2 Buddy iPhone app now supports
landscape orientation. - Settings > Keys > “Force keyboard” can now
force the keyboard layout only once per
session, the first time a newly created
terminal receives keyboard focus, instead of
continuously. This lets you switch layouts
within a session without iTerm2 snapping it
back. Restored sessions are not affected. - A new iTerm2 > Save Retrospective Debug Logs
menu item writes recently captured low-
frequency diagnostics to a file you choose,
even when debug logging was never turned on. - Launching processes no longer briefly blocks
the main thread, since child processes are now
forked on a background thread. - Newly downloaded Python runtimes are now
ARM-only on macOS 27 beta and later.
Bug Fixes:
- Updated the bundled libsixel to fix a security
vulnerability in SIXEL image decoding
(CVE-2026-44637). - tmux integration now parses pane state
correctly on macOS 26 and later. A new system
NSString method had shadowed one of ours and
caused numeric fields to be dropped, which
could also crash. - Fixed a crash in Replace Selection (for
example Replace with Pretty-Printed JSON) when
the replacement had more lines than the
selection. Saved locations inside the replaced
region are now invalidated instead of drifting
onto unrelated lines. - SSH integration now runs the ssh command
through your login shell, so a custom
SSH_AUTH_SOCK (pointing at an agent that holds
your keys) is honored. This fixes “Permission
denied (publickey)” failures that only happened
with the SSH command type. Issue 12909. - Fixed dead-key composition (such as US
International apostrophe then e) losing its
accent under the Kitty keyboard protocol when
both “report all keys as escape codes” and
“report associated text” are enabled.
Issue 12906. - Fixed OSC 8 file:// hyperlinks whose path mixes
percent-encoding with raw non-ASCII characters
(such as ripgrep’s --hyperlink-format=file
output) opening the wrong file. Issue 12914. - Fixed a dashed underline on screen, with
ligatures enabled, making box-drawing lines
(such as a TUI input box) render dashed instead
of solid. Issue 12907. - On macOS Tahoe the tab activity/progress
indicator now draws as a ring around the
pill-shaped tab instead of a bar that crowded
the tab title and status text. - OSC 9;4 progress bars now draw over the
profile’s background color instead of a fixed
black or white background, so they blend with
your theme. Issue 12916. - In the Minimal theme, when panes in a tab have
two or more different tab colors, each pane’s
title bar is now tinted with its own tab color
so you can tell them apart. Issue 12903. - Saved and copied screenshots now fill margins
and other uncovered areas with the session’s
background (including a background image)
instead of leaving them transparent. - Fixed dead space in tabs when tab numbers are
hidden. - Lock Layout is now enforced more thoroughly.
Opening a URL or file that would split or add a
tab to a locked window now opens a new window
instead, and menu items for blocked operations
(split, close, move, swap, and duplicate) are
disabled. - AI orchestration chats now handle full-screen
apps that use the alternate screen (such as
Claude Code): they read the current screen and
can use a new scroll-wheel tool to page back
through the app’s own history. - Fixed oversized gaps between messages when the
inline chat panel first reveals an existing
chat. - Reinstalling the Claude Code integration now
corrects a stale cc-status hook path left
behind by a named instance.
Notes 3.7.0beta7 (`docs/notes-3.7.0beta7.txt`)
Version 3.7.0beta7 was built on July 15, 2026.
Note: if you are beta testing the iPhone companion
app iTerm2 Buddy, you'll need to update it after
upgrading iTerm2 to this version.
Changes since 3.7.0beta6:
New Features:
- Added support for multiple AI providers at
once. - AI orchestration now safety-checks everything
the agent does in a session, not just once
when you first approve the session. Commands
it runs, commands it types at a shell prompt
and submits, and keystrokes it sends into a
full-screen program (vim, less, a coding
agent, and so on) are each judged against
what you asked for. The check no longer hard-
blocks anything: a scary-but-legitimate
action, such as deleting a directory you just
created, is held for one-tap approval instead
of being refused, while a real root or home
wipe, or an unrequested dangerous command,
stops to ask first. - Code Review sessions in a Claude Code
integration workgroup have an Auto-Send
Clippings When Idle toolbar toggle. When on, the
review session’s clippings are pasted into the
workgroup’s main session each time the review
finishes. The Claude Code workgroup’s review
peer includes it by default; the toggle
itself starts off. - The main session in a Claude Code integration
workgroup has a companion Auto-Request
Review When Idle toolbar toggle. When on, a
code review is requested from the workgroup’s
sole code-review session each time the main
session goes idle. It is disabled unless the
workgroup has exactly one code-review
session. The Claude Code workgroup’s main
session includes it by default; the toggle
starts off. - Chats can now be muted from the iTerm2 Buddy
app. A muted chat sends no push notifications
to the phone, even for permission requests,
until it is unmuted. The muted set is
remembered on the Mac so it applies even
while the phone is unreachable. - When you load settings from a custom folder,
snippets, global notes, and session icon
customizations can now sync along with your
other settings. Because notes and snippets
can hold sensitive text, iTerm2 asks once
before including them. If the same item
changed both on this Mac and in the folder,
it asks which copy to keep rather than
overwriting your changes.
Improvements:
- The built-in AI model list is now data-driven
and refreshes in the background, so new
models appear without updating iTerm2. - AI chat messages have a cleaner layout:
proposed commands, command output, and
terminal-command transcripts now render as
rounded Markdown code blocks, and
orchestration tool calls show as command
blocks instead of raw session GUIDs and JSON.
The chat list can be hidden and shown without
reopening the window, and history entries can
be selected in groups and deleted, or renamed
in place. - The Companion app’s live video feed now
scales its bitrate to the window’s
resolution, so large windows stay sharp
instead of being pinned to a fixed low rate.
Very large windows trade frame rate for
sharpness so bandwidth stays reasonable.
Advanced settings can tune the bitrate
multiplier and the flow-control thresholds. - A Diff workgroup session now waits to run its
diff until you first switch to it, so the
diff reflects your changes at that moment
instead of from whenever they first began. It
is not re-run when you switch away and back.
Bug Fixes:
- Smart tab-title truncation no longer flickers
between head and tail truncation when a tab
title begins with an animated status glyph
(such as a Claude Code spinner). The glyph is
no longer counted when deciding which end of
the title to truncate. - Session Status tool: with “Merge workgroup
statuses” on, a merged row now shows the
status of the peer you’re actually looking at
(the visible peer of the switcher) instead of
whichever peer changed most recently. A busy
code-review peer holding a steady “working”
status no longer loses the row to a sibling
chat peer the moment it goes idle. - Tab subtitle: when a tab is split into
multiple panes, the status of the pane you’re
focused on now drives the subtitle instead of
a background pane winning on status priority.
A focused pane with no status falls back to
the highest-priority sibling pane. - Code review and diff workgroup sessions now
restore showing their last output instead of
launching a stray replacement shell when the
original program is gone. The code review
prompt panel reappears only if it was open
when you quit, and its reload button
remembers the prompt you last submitted. - Session restoration no longer breaks when
iTerm2 receives a signal (such as ^C when it
was started from a terminal): the background
process server is now insulated from signals
sent to iTerm2, so your running programs
survive to be reattached on the next launch. - Fixed a rare case where a Claude Code
workgroup was torn down even though its
claude process was still running, due to a
momentary misread of the foreground job
ancestor list. - The Claude Code integration’s session status
now stays “working” while background agents
or tasks are still running, so AI chat
watchers no longer fire during the idle lulls
between subagent phases. Requires Claude Code
2.1.198 or later. - AI chat: fixed a race where a streamed tool
call could be dispatched with empty arguments
when the response completed before its
argument chunks were consumed. The agent
would loop retrying the same call, and the
turn could end with an empty message.
Finalization now reconciles against the full
response body. - AI chats reopened after a restart now replay
their tool-call history to the model
faithfully. Reasoning state (OpenAI encrypted
reasoning, Gemini thought signatures) is
saved with the chat and replayed, so
reasoning models no longer reject reopened
chats or invent fake tool results. A chat is
also locked to the provider it started with,
and messages sent from the iTerm2 Buddy app
run on the chat’s own model instead of the
global default. - The AI chat wire log now redacts API keys in
request headers, URLs, and bodies, keeping
only a short fingerprint. - In AI orchestration, pressing Stop now halts
the whole pipeline: it cancels the in-flight
reply, drops any queued turns, and cancels
the chat’s registered watchers, so a session
going idle can no longer restart the loop
after you asked it to stop. A message you
type also jumps ahead of pending status
updates instead of waiting behind them. - Closing the inline AI chat panel now returns
keyboard focus to the terminal. - Switching to a Diff peer for the first time
now keeps the terminal focused once its
command starts. Previously keystrokes went
nowhere (and beeped) until you clicked the
terminal. - Companion pairing now retries with capped
backoff instead of reconnecting (and, during
pairing, regenerating the QR code) many times
per second when the relay is unreachable, for
example because of a bad TLS certificate.
Connection failures during pairing now report
the underlying error instead of an unhelpful
generic message. - The iTerm2 companion relay servers moved to
new addresses. If you paired an iPhone on an
earlier build, iTerm2 now asks you to pair it
again so it keeps working. - Companion: when the relay hits its daily data
limit, the Mac now shows “Daily relay data
limit reached” in Companion settings and
backs off instead of retrying in a tight loop
until the limit resets. - When a companion device is paired, your AI
API keys are now loaded at launch, and when a
phone pairs, so a query sent from your phone
while you are away never has to prompt for
keychain access. Opening Settings no longer
reads the keychain or prompts for access. - SSH integration now loads shell integration
for a remote login shell running bash or zsh.
A remote /bin/bash is injected (only macOS’s
own un-injectable /bin/bash stays excluded),
and a remote zsh login shell no longer starts
with its integration skipped because of a
double shell wrapper. - Fixed various crashes.
Notes 3.7.0beta8 (`docs/notes-3.7.0beta8.txt`)
Version 3.7.0beta8 was built on July 26, 2026.
Note: if you are beta testing the iPhone companion
app iTerm2 Buddy, you'll need to update it after
upgrading iTerm2 to this version.
New Features:
- The it2 command-line tool now works on hosts
you reach through SSH integration.
Improvements:
- Session-bound AI chats can now include your
terminal’s visible screen and state with each
message automatically, and read the screen on
request with the same get_screen_contents tool
the orchestrator uses (in a full-screen app it
returns just the current screen, with no
scrollback). The “View History” permission is
now “View Contents” and gates both. iTerm2 asks
once before it starts sharing your screen
automatically, so a permission left at “Always”
from an earlier version shares nothing until
you confirm. - AI chat can now restart a session for you. The
“Type for You” permission was renamed “Control
Terminal” and now also gates this. - An AI chat linked to a terminal session now
stays linked when the session’s shell restarts
(which happens often with the Claude Code
integration). Previously the link, its command
permissions, and orchestrator @-mentions of the
session were lost on every restart. - AI chat orchestration is now much less chatty.
While it runs a loop (for example, waiting for
a Code Review to finish and then telling a
session to continue), it reports one line per
real outcome instead of narrating every step
and asking what to do next each time. - Companion push notifications are now coalesced
so you get fewer notifications. - The keychain permission prompt that appeared
after upgrading, for AI API keys and for a
paired companion device, no longer recurs on
every update. Those secrets now live in an
entitlement-scoped keychain group that stays
readable across signed builds. Upgrading to
this version prompts once more to move them. - Tab dragging is smoother: Metal stays enabled
for normal drags, reorder slots switch near tab
midpoints, and split-pane drop targets are more
stable. - Performance improvements.
- The retrospective debug log no longer
retains typed characters, command lines,
passwords, tokens, or AI chat content. These
could previously accumulate in memory even with
debug logging off. - The screenshot window’s Copy to Clipboard
button now flashes a “Copied!” confirmation so
you get feedback that the image was copied. - The settings search field again uses the system
focus ring on macOS 26.5.1 and later, where a
workaround for an earlier OS bug otherwise left
stray fragments below the capsule.
Bug Fixes:
- An AI chat linked only to a web browser now
uses a browser-appropriate system prompt
instead of one that described a terminal it was
not attached to. - Fixed a memory corruption bug that could crash
iTerm2 intermittently, most often when closing
a tmux window caused surviving sessions to
resize while output was still streaming. Lazily
cached line-wrapping info could be written into
scrollback storage shared with another thread.
Issue 12941. - Fixed a hang where a modal sheet (such as an
alert) whose parent window was destroyed before
it was dismissed left the main thread frozen in
the modal run loop forever. - Fixed key reporting using stale flags after
switching between the main and alternate screen
buffers, which could leave modified-key input
wrong for some full-screen apps. Issue 704. - OSC 22 pointer shapes are now honored while
mouse reporting is on, instead of always
forcing the I-beam-with-circle cursor. Added
the fleur, watch, plus, and minus shapes and
mapped X_cursor to the not-allowed cursor.
Issue 700. - Fixed flag emoji (such as a country flag)
rendering as blank cells or flickering under
the GPU renderer. The two regional indicators
that make up a flag are now shaped together,
matching the non-GPU renderer. - Command-clicking links in terminal output no
longer crashes when command-mark data is
unavailable. - Hiding a hotkey window on macOS 26 no longer
briefly flashes background iTerm2 windows to
the front. The window now waits for the
previously active app to come forward before
ordering out. - Global search no longer shows blank snippets
for results found on the main screen while an
alternate-screen app (such as a full-screen
TUI) is running. Snippets are extracted from
the same snapshot the search ran against. - Fixed a case where a Claude Code workgroup pane
(such as Diff or Code Review) could close
itself when its command exited and take the
whole workgroup down with it. Spawned workgroup
panes now pin their end action so the profile’s
“Close Sessions On End” setting can no longer
flip them to close on their own. - Reloading a diff session in a workgroup now
re-runs the command matching the file picker’s
current selection. Previously, if the file you
were viewing stopped having changes and the
picker snapped back to “All Files”, reload
reran the old per-file diff, found nothing, and
exited, closing the session. - Fixed a black bar and off-by-a-row click
hit-testing when filtering, taking a
screenshot, or zooming lines in a session that
carries extra chrome such as the workgroup
peer-mode toolbar. Instant replay also restores
the window to its original size on exit. - The built-in web browser now prompts for a user
name and password when a site returns an HTTP
authentication challenge (Basic, Digest, or
NTLM) instead of failing to load. - A saved status bar layout whose RPC-provided
text component is missing its registration
request is now dropped instead of showing up as
a blank “ghost” cell. - Fixed modified keys such as Ctrl-J and
Shift-Enter not reaching some apps (for example
Codex) running under tmux integration
(tmux -CC). iTerm2 now hands these keys to tmux
by name so tmux encodes them just as it does
outside control mode. - Fixed auto-sending code review results to the
main session sometimes failing to submit
because the Return arrived too soon after the
paste. The Return now waits a moment (see the
workgroupAutoSendSubmitDelay advanced setting). - If you never paired a companion device, iTerm2
no longer touches the companion keychain items
at launch, avoiding a spurious keychain
permission prompt for people who alternate
differently-signed builds (such as a released
build and one built from source). - Fixed various crashes.
Notes 3.7.0beta9 (`docs/notes-3.7.0beta9.txt`)
Version 3.7.0beta9 was built on August 3, 2026.
Changes since 3.7.0beta8:
Heads up: future betas will require macOS 13
(Ventura) or later. This is the last version to
support macOS 12. Sorry for the inconvenience!
New Features:
- iTerm2 > Suppressed Alerts is a new panel that
lists warnings you silenced with "remember my
choice" so you can turn them back on. For
example, answering No and checking the box on
the prompt that asks before displaying a
terminal-initiated image silently disabled all
inline images with no obvious way to undo it;
now you can find that choice and clear it. The
old "Always Show Alerts with Remembered
Selections" command moves into this panel.
Issue 12963. - The manual AI model editor now offers one-tap
presets for OpenAI-compatible providers
(Ollama, OpenRouter, z.ai, Qwen, MiniMax) that
fill in the endpoint for you, plus a Test
Connection button that verifies your settings
and API key before you save.
Improvements:
- Web URLs handed to iTerm2 (by the OS as your
default browser, by "open -a iTerm2 ", by
Velja, or by vim's gx) now open in the built-in
web browser instead of launching a terminal,
unless you have bound the scheme to a specific
profile. If the browser plugin is not
installed, iTerm2 offers it once. Issue 12431. - Right-clicking an auto-detected URL (the kind
⌘-click opens) now offers Copy URL, which
removes the hard line breaks from a wrapped URL
so a clean link lands on the clipboard.
Issue 12944. - Right-to-left script support (bidi reordering
plus Arabic and Persian cursive joining) is now
enabled by default. You can turn it off in
Settings > General > Experimental. Issue 4361. - AI chat history is now replayed to the model
from the exact bytes previously sent rather
than rebuilt each turn, so the prompt cache
stays valid across turns (including turns that
ran a tool) and long conversations keep getting
cheaper and faster. - Added a Zero Data Retention setting under
Settings > General > AI. Turn it on if your
OpenAI organization has Zero Data Retention
enabled. iTerm2 then stops sending
previous_response_id and sets store=false, so
chats no longer fail on the second message.
Issue 12521. - An AI chat linked to a terminal session can now
watch that session and tell you when it reaches
a described state or condition, without
polling. Previously only orchestration chats
could set watches. - ⌘-click now linkifies very long URLs in full.
Previously only the first couple thousand
characters were clickable; the linkifier now
scans onward to the next separator, handles
internationalized (IDN) hostnames, and no
longer joins a URL to unrelated text across a
hard line break. - When AI chat types into a terminal with the
insert-at-cursor tool, a newline is now sent as
Return so it submits the line in programs like
Claude Code instead of inserting a literal
newline. - Preferences search now finds several Colors-tab
settings that were previously unsearchable,
such as Border around active pane, Use custom
tab color, and the light/dark color controls. - The Script Console has a Clear Terminated
button that removes stopped script and
connection entries, so it no longer grows
without bound (and slows the app) when you
repeatedly restart a script. Issue 12942. - A new tmux window opened with ⌘T now picks up
the profile's custom tab title, if one is set. - iTerm2 now probes the local network on every
launch so macOS keeps it in the Local Network
privacy list. This recovers the permission
after a tccutil reset or an OS upgrade, fixing
commands like "ping 10.0.0.1" failing with "no
route to host". Issue 12956. - Faster rendering of text with many colors (such
as syntax-highlighted code) by reusing
attribute dictionaries across color spans
instead of rebuilding one per span. Issue
Bug Fixes:
- Fixed a local privilege issue where the
orphaned-session server adopter connected to a
socket in a world-writable directory without
checking who owned it. A local attacker could
pre-create the socket and man-in-the-middle a
restored session, capturing keystrokes and
output. The peer's identity is now
authenticated before anything it sends is
trusted. - Fixed a way for an authenticated Python API
client to exhaust iTerm2's memory by flooding
it with WebSocket ping frames while never
reading the replies. Inbound buffering is now
bounded and such a client is disconnected.
Credit to NSEcho. - A sixel image whose String Terminator was
written as ESC ESC \ instead of ESC \ is no
longer discarded, leaving a stray backslash on
screen. The image now renders. Issue 12259. - ⌘-click again opens files in JetBrains IDEs
(IntelliJ IDEA, WebStorm, Rider). iTerm2 now
invokes the IDE's launcher directly so the file
opens in the already-running instance instead
of silently starting a new one. Issue 12958. - Toggling Caps Lock under the Kitty keyboard
protocol with "report all keys as escape codes"
no longer sends an "A". It now reports the Caps
Lock key's own code. Issue 12922. - Fixed the Kitty keyboard protocol SET request
destroying the whole flag stack instead of
modifying only the current frame. - The OSC 9;4 progress bar drawn in the tab is no
longer hidden by the active-pane border when
both are enabled. Issue 12960. - In tmux -CC sessions with pane-border-status
set to top or bottom, panes are no longer one
row too tall (the bottom line went unused and
the cursor landed a row high after
reattaching). Issue 12925. - tmux panes no longer receive stray focus-report
escape sequences when the server's focus-events
option is on. Focus reporting now turns on only
when the pane's own program requests it, so it
no longer pollutes the shell prompt or flips
bash's vi mode. Issue 12933. - Make Screenshot no longer drops ASCII
programming ligatures (-> => != and so on) when
the profile and font support them. - The Centered window style now looks and behaves
like a normal window (title bar, the close,
minimize, and zoom buttons, and the merged
chrome of the
Minimal and Compact themes), except that it
stays centered. It also no longer changes size
when you minimize and restore it. Issue 12918. - A left- or right-of-screen window no longer
changes width when you add a tab whose profile
has a different width. Issue 12917. - Split-pane dividers in different rows or columns
can now be dragged into alignment: a divider
snaps to a matching divider elsewhere in the
tab when it comes within half a cell, fixing a
sub-cell misalignment that whole-cell drag
steps could not correct. Issue 12932. - Modified function keys (such as Shift-F5) no
longer lose their modifier in a tmux
integration (tmux -CC) session, which sent the
unmodified sequence instead of the xterm form.
Issue 10717. - The suggested filename for Save contents no
longer contains colons or slashes, which macOS
rendered as "/" in the save panel and Finder.
Issue 12931. - The command info popover's "Started at" time
now honors the system 24-Hour Time setting
instead of always using a 12-hour clock.
Issue 12929. - Fixed several VoiceOver problems in the
terminal: QuickNav now engages without toggling
it off and on, content no longer goes stale
when output arrives during an interaction, and
the review cursor no longer jumps to the top.
Issue 12920. - Reshaping a tab through the Python API's
apply-layout call no longer leaves the window
with no focused pane, so typing works without
first clicking a pane. - Switching between the main and alternate screen
buffers no longer resets a manual key-reporting
choice when both screens report the same flags. - Pressing Undo Close when the last closed
session cannot be revived no longer leaves an
invisible empty window behind, which also
crashed Python API scripts with a runaway
refresh loop. - Fixed a black-holed companion relay connection
that could leave the Mac believing it was
connected for hours over a socket the relay had
already dropped. A keepalive ping that gets no
response within ten seconds now tears the
socket down and reconnects. - A three-finger drag over an app with mouse
reporting on (tmux with mouse on, or vim with
mouse=a) no longer produces both the app's own
selection and a native iTerm2 selection at
once. - Fixed various crashes.
Notes 3.7.1 (`docs/notes-3.7.1.txt`)
Version 3.7.1 was built on August 19, 2026
New Features:
- The OSC 1337 SetColors escape sequence now
accepts a palette index as the value, written
i:N where N is 0-255. Indices 0-15 track the
loaded color preset, so you can set the badge
color to follow the preset instead of hardcoding
an RGB value. For example, ESC ]1337;
SetColors=badge=i:3 ST uses ANSI yellow.
Other Improvements:
Bug Fixes:
Notes 3.7.4beta1 (`docs/notes-3.7.4beta1.txt`)
Version 3.7.4beta1 of iTerm2 was built on
September 29, 2026.
Credit to Nimanou (nuved) for the right-to-left
rendering, selection, and copy improvements in
this release.
Major New Features:
- iTerm2 is now localized: the interface follows
your Mac’s language and is available in
Brazilian Portuguese, German, Japanese, Russian,
and Simplified Chinese.
New Features:
- Ollama is now a first-class AI provider
alongside OpenAI, Anthropic, Gemini, and
DeepSeek. Choosing it lists the models installed
on your local Ollama server automatically,
reading each model’s context window and
capabilities (thinking, tools, vision) from the
server, and newly pulled models show up on
refresh. When Ollama is your default provider
you pick which model to use for regular chats
and an optional smaller one for quick background
tasks. The chat’s Think toggle now controls
whether a reasoning-capable local model thinks,
and the context window is sized to your prompt
so long prompts are no longer silently
truncated. A non-local Ollama server can still
be added as a manual model, where a Fetch Models
button lists its models and a Vision checkbox
lets image-capable models accept images. - The OSC 1337 SetColors escape sequence now
accepts a palette index as the value, written
i:N where N is 0-255. Indices 0-15 track the
loaded color preset, so a badge or tab color can
follow the preset instead of a hardcoded RGB
value. For example, SetColors=badge=i:3 uses
ANSI yellow. Issue 12991. - New OSC 1337 codes let a program move and resize
its window across monitors using global
coordinates: SetWindowFrame=x;y;w;h sets the
frame, while ReportWindowFrame and
ReportScreenFrames report the current frame and
the layout of all displays. Unlike the older CSI
3;x;y t sequence, these are not limited to the
monitor the window is already on. - iTerm2 now answers CSI 16 t (report character
cell size), which was previously ignored. - Window > Lock Location pins a window to the
display and position it has when you turn it on.
If anything moves the window it goes right back,
and when that display is disconnected, as
happens while a laptop sleeps, the window waits
and returns when the display comes back.
Dragging the window turns the lock off. Styles
that fill or attach to the screen, such as
Maximized, Centered, and the edge-attached
styles, keep their usual shape and are held to
their display instead. Note that macOS puts a
returning window on whichever Space its display
is showing at the time, which may not be the
Space it left. Issue 13031. - There is a new “Centered, No Title Bar” window
style, in Settings > Profiles > Window > Style
and in the Window > Window Style menu. It stays
centered on the screen like the Centered style
but has no title bar or window controls, which
is how Centered windows looked before 3.7.0.
Issue 13043. - The window name can now appear beside the tabs
in the Compact and Minimal themes, which have no
title bar to show it in. By default it appears
only once you name a window with Window > Edit
Window Title, so windows you have not named look
exactly as before. Double-click the name to
rename the window. - ⌘Z now undoes text editing in the field you’re
editing (the composer, browser address bar,
annotations, notes, and AI chat), and ⌘⇧Z
redoes. Undo Close is now solely Shell > Undo
Close (⌘⇧T). The first time you press ⌘Z with a
recently closed session still restorable, a
one-time notice explains the change and lets you
choose Undo Close or a regular undo. - A new “Prevent sleep” profile setting keeps the
Mac from idle-sleeping while a session with the
setting enabled exists. By default it applies on
AC power only. - A new “Set Profile Setting” trigger action turns
a profile checkbox on or off for the session.
Paired with Job Started and Job Ended matches,
it gives “while this job runs” logic, such as
preventing sleep only while a long-running job
like an AI coding agent runs. - New per-profile “Use HDR cursor when available”
setting in Settings > Profiles > Colors. When
on, a white cursor renders brighter than white
on an HDR-capable display, with either the GPU
or the legacy renderer. A slider next to it sets
how bright the cursor gets. - The built-in web browser can now remember HTTP
basic-auth user names and passwords. Check
“Remember this password” in the sign-in dialog
and it is saved in the browser password manager
(Keychain, 1Password, and the other supported
back ends) and offered automatically next time.
The dialog also has a Password Manager button to
pick an existing login. - The password manager’s settings menu now offers
one choice of how often to ask for Touch ID or
your password: never, once per launch, once per
launch and after the screen locks, or every time
it opens. The last option is new, and it also
closes the password manager when the screen
locks. - Dragging a tab into a collapsed tab group now
adds it to that group. The group stays collapsed
unless you dragged the active tab, in which case
it expands. While you drag, the group’s pill
highlights to show the tab will drop inside it. - You can now press ⌘F in an AI chat to search the
current conversation. A find bar appears at the
top with match counts and next/previous
navigation, and matches are highlighted in
place. Supports smart-case, case-sensitive, and
regular expression modes. - Right-clicking your own message in AI chat now
offers Delete, which removes that message and
everything after it. Edit, Fork, and Delete are
also available when right-clicking inside the
message text. Editing or deleting a message also
syncs to a paired iPhone viewing the same chat,
and the phone can delete messages too. - AI chat can now set a timer to act after a delay
or at a set time (for example, “in two hours,
run this command”). The timer survives restarts,
fires immediately if its time already passed,
and can push your phone when it fires. - AI command suggestions can now recognize when
you have typed a plain-English request instead
of the start of a command. In that case the
suggested command replaces what you typed rather
than being appended to it. - The Cockpit panel can now send a command to many
sessions at once from a composer docked along
its bottom. Type @ to pick a session, tab, or
window, or select rows in the list above it; the
rest of what you type is sent to each of them
verbatim. Return inserts a newline and
Shift-Return sends. The Cockpit also shows each
session’s live status in every grouping, with a
filter bar along the top that narrows the list
to one status. - The workgroup toolbar can show an AI Usage gauge
with your Claude subscription’s session and
weekly usage as progress bars. Add it to a
session’s toolbar in the workgroup settings and
choose how often it refreshes; it reads the
numbers by running the Claude Code command-line
tool. You can point it at your own command
instead. - The Session Status tool can now follow Codex CLI
as well as Claude Code. There is no installer
for Codex yet: write a small shell script that
runs the hook program inside iTerm2.app
(Contents/Resources/utilities/cc-status) with
the argument --agent codex, then in
~/.codex/hooks.json point each event at that
script and set timeout to 3 on the Interrupt and
SessionEnd hooks, since the one second Codex
allows them by default is not always enough. The
status dot then tracks the agent through a turn,
names what a permission request wants to do,
clears when you interrupt with Esc, and keeps
showing work in progress while a detached
sub-agent runs on. The tab gets a progress
indicator too, which Codex does not draw for
itself. - A session status can now say when it stops being
true. Along with the status, a program says what
the status should become once the work it
describes is over, and iTerm2 makes the change
when the program reports that the work finished.
This keeps a status from outliving the thing it
was reporting on when a program is stopped
partway through. Set it with the --expires-on
option of it2 set-status, or the expires_on
argument of the iterm2.set_status function in
the Python API. - The session status is now available in the
session-scoped variable “status”. - it2 can now target a session by the tmux pane it
is showing. From inside a tmux Integration pane,
pass --tmux "$TMUX" --tmux-pane "$TMUX_PANE" to
session set-status, get-status, set-var, or
get-var; a program running there cannot
otherwise learn which session it is in. This
works the same on a host reached through SSH
Integration. Claude Code’s status is no longer
reported from a pane of a plain tmux session
(one not using tmux Integration), since iTerm2
cannot tell which tab, if any, is showing that
pane. - New “it2 session get-status” prints a session’s
status indicator as JSON. set-status no longer
requires --session and defaults to the active
session. - The it2 command-line tool can now send a push
notification to your paired companion device
with “it2 notify «title» «body»”. - The Python API can now discover and change tab
groups, read and write a session’s status
indicator and Session Note, and find the session
showing a tmux pane.
Improvements:
- iTerm2 now recognizes when a terminal is running
on your own Mac by machine identity rather than
by hostname, so features that only work locally
keep working over a VPN or Tailscale connection
where the hostname no longer matches. Reinstall
shell integration (iTerm2 > Install Shell
Integration) to enable this. - Pairing a companion iPhone no longer requires
AI. The setup wizard (iTerm2 > Companion Device
Settings) now lets you choose “Terminal Viewing
& Control Only”, so you can browse sessions,
watch live output, and type from your phone
without an AI plugin or API key. Chat features
appear disabled with an explanation until you
turn AI on. The phone’s session list also
updates live as sessions come and go. - Reduced idle CPU usage that previously grew with
the number of open windows. - Hidden hotkey windows no longer redraw at the
full active cadence. Sessions with continuous
output (builds, tails, agents) in a hidden
hotkey window used to burn CPU and GPU rendering
frames nobody could see, spinning fans on
laptops. Hidden windows now drop to the
background refresh rate and repaint the instant
they are revealed. - The Snippets tool no longer rebuilds its list
every time you switch tabs or the running
command changes, which had been making windows
slowly get slower the longer they stayed open. - Reduced disk writes from saving restorable
session state. Sessions with no new output are
no longer rewritten on every save. - Keystrokes sent to a host over SSH Integration
no longer wait for the previous one’s network
round trip, so typing faster than the latency no
longer arrives in bursts. Issue 13013. - A program that changes its title rapidly inside
a tmux Integration session, such as a spinner in
Claude Code’s title, no longer causes needless
work several times per second. - The AI timeout is now an idle timeout (the
maximum gap between bytes received) rather than
a cap on the total time a request may take. A
slow but healthy generation, such as a reasoning
model that is still thinking or a cold local
model, keeps running as long as it makes
progress, while a dead connection still fails.
The default was also raised to 20 minutes. This
fixes spurious timeouts against local Ollama
servers. - Custom HTTP headers for AI requests are now set
per manual model in the model editor, not one
global list, so each endpoint can send its own
auth header. This lets an authenticated
self-hosted or local endpoint work by adding its
Authorization header. Existing global headers
are copied to your saved models. The editor also
shows which API key (if any) each model uses.
Issue 12975. - AI command safety checks now use the cheaper
“economy” model when one is available, so the
frequent low-stakes safety check costs less. It
falls back to your configured model when no
economy model exists. - The Move Tab Left and Move Tab Right shortcuts
now move just the current tab instead of its
whole tab group. Moving a tab past the edge of
its group pops it out of the group, and moving
an ungrouped tab into a neighboring group adds
it to that group. - Duplicate Tab now opens the copy immediately to
the right of the original tab instead of at the
end of the tab bar. - The tab context menu has been reorganized.
- With the tab bar on the left or right, the tab
and tab group context menus now say “New Tab
Below” and “Close Tabs Below” instead of “to the
Right”, matching the direction the tabs actually
run. - The Session Status toolbelt tool has a snooze
button next to the notify button that toggles
snooze on the selected session. - The git branch shown in the session toolbar can
now be right-clicked to copy its name, or
dragged into a text field to paste it. Available
only while a branch name is showing, not during
a rebase or similar. - The Current Directory status bar component’s
folder icon is now a proxy icon: drag it to
another app to drag the session’s working
directory, like the proxy icon in a window title
bar. - The Interpolated String status bar component now
shows [undef: x] for a reference to an undefined
variable instead of silently rendering an empty
string, making typos and unavailable variables
obvious. Use (x?) to treat a missing value as
empty. - Settings search now reveals controls that live
on an inner sub-tab (such as AI > Features). It
switches to the correct nested tab before
highlighting, instead of dimming a bogus area. - Browser tabs now show a title (page title, or
the host, or the profile name) instead of a
blank tab and window title. In the Minimal
theme, a browser tab’s light or dark appearance
and tint now follow the page’s background color
instead of the profile’s terminal background
color. - The tab group rename and creation dialogs now
match the light/dark appearance of their window
in the Minimal theme. - Dropping a text file into a session connected to
a remote host now offers Paste as Text, which
types the file’s contents into the terminal.
Dropping several files there now offers “Upload
and Paste Paths”, which uploads them all and
then types their remote paths onto the command
line. Local paths are no longer offered there,
since they name nothing on that host. - The About window now follows the current theme,
updating live when you switch between light and
dark.
Bug Fixes:
- Fixed crashes when editing a dynamic profile
whose file had changed on disk, clicking the
bookmark button on a command, quitting while
several Claude Code sessions were running,
asking for notification permission, using a
tab’s context menu, running a global search,
saving a debug log, tearing down a maximized
split pane (such as when a tmux connection
dropped), attaching with tmux -CC to a window
whose panes are all floating, writing a
hyperlink whose address contained an emoji,
drawing a combining mark after a box-drawing
character, and in the GPU renderer on macOS 26. - Make Text Bigger works again when you type it as
⌘⇧= (the way you type ⌘+). Since 3.7.0, that
keystroke opened the Clippings panel instead,
because Show Clippings had claimed it. Show
Clippings is now ⌘⇧⌥G. Issue 13022. - Fixed a performance regression from 3.7.0 that
made typing lag or arrive in bursts when using
terminal multiplexers (such as tmux -CC) and
other programs that query the terminal’s colors
or cursor position, especially when switching
tabs or windows. Issue 13035. - Fixed severe typing lag in a tmux Integration
session running on a host you connected to with
an SSH profile. Keystrokes and the output they
produced were each held back about a second, so
typing arrived in slow bursts. - Fixed occasional delayed screen updates in a
session that had gone quiet. A redraw with no
other output behind it could be held back until
something else happened in the session. - After iTerm2 restarted with your sessions
restored, which happens after an update, a
crash, or a force quit, commands in newly opened
sessions could fail to reach devices on your
local network with “No route to host” until you
quit and reopened iTerm2. New sessions now get
local network access right away. Sessions
restored from before the restart still need to
be restarted to regain it. Issue 12106. - Fixed a bug where auto-update failed with a
message about running from the download location
when iTerm2 was unzipped or moved to an
Applications folder from the command line rather
than dragged in the Finder. iTerm2 now fixes
this automatically on launch, and no longer
offers to move a copy that is already installed. - Fixed a beachball with a large file, or a file
on a slow disk, copied from the Finder. Opening
Edit > Paste Special read the whole file just to
decide whether to enable Paste File
Base64-Encoded, and pasting read it again before
offering the paste options. Opening the menu no
longer reads the file at all, and pasting looks
at only the first 50 KB. - Fixed a lag when switching or closing tabs with
the Session Status tool open in the toolbelt. - Stopped falsely offering to reset the key
reporting mode after every command when your
shell manages the keyboard protocol itself, as
Fish with oh-my-posh and similar prompt tools
do. Issue 13032. - Fixed extended key reporting being lost after a
restart with session restoration on. An app that
had turned on modifyOtherKeys (so keys like
Shift-Enter send a distinct code) now keeps
working when its session is restored, instead of
quietly reverting to plain keys. - In a tmux Integration session, keys such as
Shift-Enter are now encoded the same way before
and after a detach and reattach. Previously a
pane could encode them one way before a detach
and another way after, until the app was
restarted. With tmux 3.9 or later, iTerm2 lets
tmux encode each key for the app in the pane,
including with the Kitty keyboard protocol.
Issue 13076. - Fixed Kitty keyboard protocol base-layout
reporting for non-US keyboard layouts, so
shortcuts such as Ctrl-C work without switching
layouts. - Fixed a bug where pressing Backspace while
composing text with an input method such as the
Korean 2-Set IME could leave a stray character
behind that needed an extra press to remove.
Pressing Return while composing now submits the
line in one press. Issue 13030. - Fixed a bug on macOS 26 where pressing ⌘Tab to
leave a full screen window would flash the other
app and immediately switch back to iTerm2,
sometimes leaving you unable to change apps
without the mouse. This affected windows using
the older full screen style with menu bar hiding
turned on. - Fixed a bug on macOS 26 where the tab bar became
short and shifted down after entering and
exiting Lion fullscreen, and where the tabs sat
jammed against the top of the screen in
fullscreen. Affects the Tahoe tab bar with tabs
on top. - On macOS 26, the tab bar in maximized, centered,
and single-use windows now sits in the title bar
as it does in normal windows, instead of drawing
a mismatched background with a dark separator
line under the title bar. - On macOS 26, the progress ring around a tab is
no longer missing from the first and last tabs.
A tab that is partly scrolled under the window
controls now keeps the visible part of its ring,
and its activity indicator, instead of losing
them entirely, and the ring stays within the tab
bar’s rounded ends rather than drawing past
them. - Fixed the tab dividers in the Tahoe theme having
too little contrast in light mode and showing as
a light gray line in dark mode. - A hotkey window set to appear on all spaces no
longer switches you to a different space when
revealed after reopening the laptop lid. It is
now revealed on the current space and rejoins
all spaces afterward. Issue 12968. - The SCP-download menu animation no longer leaves
an invisible window behind. That stray window
could make clicking the Dock icon fail to open a
new terminal after the last window was closed.
Issue 12996. - Fixed scrolling that stopped working in every
tab on macOS 27 for the few people who had
turned on the old “Dismember scroll view for
better GPU performance?” setting. That
workaround targeted macOS 10.14 and has been
removed, so the setting is gone and anyone who
had it on now gets the normal behavior. - A tab name you set yourself with Edit Tab Title
is no longer replaced by the session’s name
whenever the tab’s subtitle changes or its label
is otherwise refreshed. Issue 13072. - The control sequence that resets a tab color to
the profile’s now restores it correctly when the
profile has separate light and dark mode colors
but its Tab Color predates them. It could leave
the tab with a color you never chose, often red,
or with no color at all. Issue 13058. - Fixed ⌘-number tab shortcuts becoming
non-consecutive after changing a tab group from
the context menu. Removing a tab from a group
reordered the tabs but left the shortcut numbers
on their old positions. - Opening a link from a browser tab in a tab group
(with “Open in New Tab” or a link that wants a
new window) now puts the new tab in that group
instead of leaving it ungrouped in the middle of
the group. Issue 13029. - Duplicate Session no longer stamps a permanent
working-directory override on the new session.
Previously that override was inherited by split
panes and could go stale, dropping the shell in
the wrong directory. Issue 12981. - Fixed a bug where a profile whose custom working
directory used a tilde (such as ~/www) wrongly
reported the directory as unavailable and
started in the home directory instead. - “Open Selection as URL” now works on links
without a scheme, like example.com/path, and is
hidden when the selection isn’t a URL. Issue - Archive Sessions on Closure failed with “The
file doesn’t exist” whenever the session’s name
contained a slash, such as a title that shows
the tty or the current directory. Slashes and
other characters that can’t appear in a filename
are now replaced with underscores, the time in
the archive name uses periods instead of colons
so Finder shows it correctly, and very long
names are shortened to fit. The same cleanup now
applies to automatic session log filenames.
Issue 13094. - When a tmux Integration window first loads, its
tab now shows the finished title right away. It
used to briefly show a bare session name, with
neither the “↣” marker that flags a tmux tab nor
the second line under the title. - Attaching with tmux Integration no longer fails
when a pane’s scrollback contains raw tmux
control-mode text (for example, from an earlier
tmux -CC run in a terminal without tmux
Integration). iTerm2 mistook a %exit line in
that text for tmux quitting, dropped the
connection, and printed the rest of the protocol
stream into the window. - Fixed tmux Integration sessions not resizing
when the tab bar hides or shows in full screen
with “Hide tab bar when there is only one tab”
enabled, which left an unused strip at the
bottom of the window. - Fixed tmux pane sizing when a per-session
toolbar (such as the workgroups toolbar) is
shown. Panes were told they had more rows than
actually fit, so content rendered behind the
toolbar and appeared to jump. - Fixed the filter bar briefly matching every line
when you changed the query while a search was
still in progress, such as by typing quickly. - Interrupting Claude Code with Esc now returns
its Session Status to idle. The tab kept reading
“working”, or “waiting” if a permission prompt
was open, until the next thing you asked it to
do. - The Session Status toolbelt tool now lists
Claude Code sessions running inside tmux
Integration panes on this Mac. They never
appeared before, and their status went
unreported to the rest of the app. Claude Code’s
status hook is not yet installed on remote
hosts, so sessions there still go unreported.
Issue 13049. - Installing the Claude Code Integration now
honors $CLAUDE_CONFIG_DIR, so per-tab status
indicators work for people who keep Claude
Code’s settings outside ~/.claude. It used to
always install into ~/.claude, where Claude Code
never looked. - Fixed the workgroup toolbar’s
auto-request-review and auto-send-clippings
toggles showing a stale off state after a
restart even though they were actually on, so
the button now always matches whether the
behavior is enabled. - Notify on Status Change no longer claims ⇧⌘X as
a default keyboard shortcut. That shortcut could
shadow a macOS Service bound to the same keys.
Assign your own shortcut in System Settings >
Keyboard > Keyboard Shortcuts > App Shortcuts if
you want one. - Fixed a bug where, if the AI asked for
permission in a chat and you ignored the request
and sent a new message instead, the chat would
stop responding. - Fixed a bug where an AI chat permission button
(such as “Check terminal state”) did not update
its label after you changed the setting, and
re-clicking it appeared to do nothing. - Fixed text the AI typed into a terminal not
being submitted when it ended with a newline.
Programs such as Claude Code and vim inserted a
line break instead of running the command. - Fixed a bug where the manual AI model editor’s
Test Connection button ignored the “Supports
temperature” toggle and always sent the
temperature parameter, failing against endpoints
that reject it. - Fixed a bug where the manual AI model editor’s
Test Connection button sent your API key to an
AI endpoint on your local network, even though
real requests never do. An authenticated local
server could report a successful test and then
reject every chat message. The test now behaves
like a real request, and names the header to add
under Custom headers when the endpoint wants a
credential. Issue 13021. - Fixed a bug where the host in an AI model’s URL
was compared letter for letter, so its
capitalization, or a trailing dot, changed which
API key was sent. A local address written as
http://LOCALHOST:11434 was not recognized as
self-hosted and received your provider key, and
a provider address written in capitals was
attributed to OpenAI, so the wrong key was sent. - Fixed a bug where a custom HTTP header for an AI
model whose name differed only in capitalization
from one iTerm2 sets itself (such as
“authorization”) was sent alongside the built-in
header instead of replacing it, leaving it to
chance which value the server saw. - The Companion setup wizard now saves the pasted
API key for the provider you chose in the popup,
so choosing a provider other than the current
default no longer stores the key under the wrong
service. - Fixed a bug in the Kitty graphics protocol where
transmitting a new image under an image id
already in use kept drawing the old image in
places that already showed it. The newest image
is now used, so one program no longer shows a
stale picture left behind by another. - Fixed Kitty graphics placeholder cells drawing
nothing when the image id fit in three bytes and
the program omitted the optional diacritic that
carries the high byte. Other terminals render
these, and now iTerm2 does too. - Fixed a bug where blank space rendered as a
stray “~” with the GPU renderer in profiles that
have Anti-aliased turned off. It showed up most
often in bold runs and in the padding of
full-screen programs like k9s, and it persisted
for the rest of the session once it appeared. - Fixed a bug where a smooth-blinking cursor
passed through light gray while fading in the
GPU renderer instead of fading directly to the
background color. Issue 12990. - Fixed double-width and double-height lines (ESC
#3, #4, and #6) staying double-sized after the
line was erased, scrolled, or reset. A
full-screen program that drew a large title and
then repainted could show giant, clipped text
where ordinary text belonged; entering the
alternate screen, Reset, and Clear Buffer left
the same stale lines behind. - Fixed a bug where a flag emoji took four columns
instead of two, leaving a gap after it. A flag
is now always two columns, the width other
terminals report for it, so the advanced setting
“Lone regional indicators render full-width” no
longer affects flags. - Fixed a bug where a skin-toned emoji like 👍🏻 was
drawn as the plain emoji followed by a separate
color swatch, taking four columns. It is now a
single emoji two columns wide, matching other
modern terminals. Issue 13079. - Fixed a bug where a character that combined with
the one before it could come out the wrong width
when the two halves arrived in separate writes,
leaving the cursor inside the character. - Fixed a bug where a subparameter written after a
full CSI parameter list was dropped even though
it belonged to a parameter that had been stored,
such as the 16th. - Parentheses, brackets, and other mirrored
punctuation now draw correctly in right-to-left
text when right-to-left support is on, so a
bracket around an English phrase inside Persian
is handled the same way as in other Mac apps. - Fixed misplaced letters in right-to-left words
that combine a lam-alef ligature with a
diacritic, like the Persian کاملاً and لطفاً:
the combining mark’s glyph position no longer
perturbs the display order, so the word’s
letters stay in right-to-left order and the mark
stays on its base letter. - Selecting part of an Arabic or Persian word no
longer breaks its cursive joining. Previously a
selection or a mid-word color change split the
word (for example کامل) into isolated letter
forms. - Selection on right-to-left lines is now visual:
a drag selects exactly the columns under it,
like every other terminal action. Previously the
selection was a logical range, so dragging over
a line with embedded English or digits
highlighted a different, discontiguous span
(selecting «کد پستی 12345» could light 345 but
not 12), the first character of a
right-justified line could not be selected from
the margin, and the trailing period was
unreachable. Copy converts the visual span back
to logical runs, so copied text stays in reading
order; partially selected English inside Persian
copies its selected cells only. - Extending a selection upward from a
right-justified line keeps the anchor line’s
words: the per-line share of a multi-line
selection now follows each line’s reading
direction instead of always opening on the left. - Copying a single visual sweep that covers
logically separated pieces of a mixed
right-to-left line now concatenates the pieces
in reading order instead of stacking them on
separate lines. Pieces on different lines still
copy with line breaks. - Fixed “Remember my choice” applying the wrong
choice in the dialog that appears when you paste
a file, folder, or image. The answer was
remembered by button position, so a choice saved
for a local file could turn into a different
button in a session connected to a remote host,
silently uploading the file. Each set of choices
the dialog can offer now remembers its own
answer. Any answer you had saved for these
dialogs is cleared, so you will be asked once
more. - Uploading several files at once to a host you
connected to with an SSH profile now uploads all
of them. Dropping more than one file on the
terminal, or choosing Upload after dropping
them, transferred only the first and silently
discarded the rest. Uploads over plain shell
integration were unaffected. - “Upload and Paste Path” now pastes the path the
file actually landed on. Uploading over an SSH
profile never overwrites, so a file whose name
was already taken on the far host arrived as
“name (2)” while the original name was pasted,
naming a different file. - The file paste options (Paste Path, Paste Paths,
and Upload and Paste Path) now honor the “When
pasting a dropped filename, wrap it in double
quotes” setting in Settings > General, as
dragging a file into a local session already
did. They previously always used double quotes,
ignoring the setting. - Dragging a file into a local terminal now
inserts its path again on VPN or Tailscale
networks, where it had instead been offering to
upload the file. - A floating Session Note now reappears after
iTerm2 relaunches with your sessions restored,
instead of staying hidden. - Fixed the Composer clipping the bottom of the
last line you typed, and the status bar composer
cutting off its top line after a resize. - Fixed a bug where editing only the keystroke of
a Select Menu Item or Toggle Setting key binding
erased the menu item or setting it was bound to. - Fixed the Screen setting in Settings > Profiles
Window. Choosing “Main Screen” left the popup
blank and showed no checkmark next to it when
reopened, although the setting was saved
correctly. - Fixed a bug where dismissing a confirmation in
certain Settings sheets (such as the hotkey
configuration or special exceptions panels)
would bring a terminal window to the front. - Fixed the git ahead/behind arrows in the session
toolbar staying black in dark mode instead of
matching the text color. - Installing the Python runtime used by scripts
now trusts the certificates installed on your
Mac. On networks that inspect secure traffic, it
could fail with a certificate error even though
the same download succeeded in a browser. - Fixed the Python API not reporting OSC 8
hyperlinks (a cell’s CellStyle.url is now
populated) and reporting an underline color or a
block ID on cells that did not have one, so
scripts reading screen contents no longer see
those attributes spill past where they ended.
Notes 3.7.5 (`docs/notes-3.7.5.txt`)
Version 3.7.5 was built on October 14, 2026.
Credit to Nimanou (nuved) for the right-to-left
rendering, selection, and copy improvements in
this release.
Major New Features:
- Floating Panes: a pane that floats over a tab’s
split panes. Create one with Shell > New
Floating Pane with Current Profile, or with a
key binding or pointer action. Drag its title
bar to move it and its edges to resize it. Hold
Control while dragging to dock it into the split
panes or move it to another tab or window.
Window > Floating Panes can raise, dock, move
and hide them. They are saved with window
arrangements. tmux integration shows tmux’s own
floating panes (tmux 3.7 and later; moving,
resizing, raising and docking them needs tmux
3.8). - iTerm2 is now localized: the interface follows
your Mac’s language and is available in
Brazilian Portuguese, German, Japanese, Russian,
and Simplified Chinese. - Support for the Program Status Protocol (OSC
7501), which lets a program report that it is
working, waiting on you, done, or failed. The
tab shows the most urgent status the program
reported, along with its message and progress. A
program reporting on several tasks at once gets
them listed under its session in the Session
Status tool, and each task that needs you can
post its own notification. - Session Status has new “done” and “error”
statuses for work that finished or failed while
you were away. They sort just below “waiting”,
above “working”, and “idle” now sorts last.
Pressing a key in the session marks the result
as seen and turns it idle. Claude Code now
reports a finished turn as “done” and an API
error as “error”. Customized priority lists get
the two new statuses added below “waiting”,
notifying if “idle” did.
New Features:
- Ollama is now a first-class AI provider
alongside OpenAI, Anthropic, Gemini, and
DeepSeek. Choose it in Settings > General > AI
and iTerm2 lists the models installed on your
local Ollama server, reading each model’s
context window and capabilities (thinking,
tools, vision) from the server, so there is
nothing to configure. You can pick one model for
chats and a smaller one for quick background
tasks. For a server on another machine, use the
new “Ollama (auto-discover)” or “Ollama
(native)” presets, or the manual model editor’s
new Fetch Models button and Vision checkbox. The
native API type, formerly named “Llama”, is now
named “Ollama”; it sizes the context window to
your prompt so long prompts are no longer
silently truncated, and the chat’s Think toggle
now controls whether a reasoning model thinks. - ⌘Z now undoes text editing in the field you’re
editing (the composer, browser address bar,
annotations, notes, and AI chat), and ⌘⇧Z
redoes. Undo Close is now solely Shell ▸ Undo
Close (⌘⇧T). The first time you press ⌘Z with a
recently closed session still restorable, a
one-time notice explains the change and lets you
choose Undo Close or a regular undo. - The Session Status tool can now follow Codex CLI
as well as Claude Code. There is no installer
for Codex yet: in ~/.codex/hooks.json, point
each hook event at a script that runs the
cc-status tool from the utilities folder inside
iTerm2.app’s Resources with --agent codex, and
set timeout to 3 on the Interrupt and SessionEnd
hooks. The tab then shows Codex’s status and a
progress indicator through each turn. Codex also
reports a finished turn as “done” and an API
error as “error”. - The Cockpit can now send a command to many
sessions at once from a composer docked along
its bottom. Type @ to pick a session, tab, or
window (selecting rows in the list does the
same), then type the command and press
Shift-Return to send it to every chosen pane.
The Cockpit also now shows each session’s live
status and detail line in every grouping, and a
filter bar along the top narrows the list to one
status. - A new “Set Profile Setting” trigger action turns
a profile checkbox on or off for the session.
Paired with Job Started and Job Ended matches it
gives declarative “while this job runs” logic. - A new “Prevent sleep” profile setting keeps the
Mac from idle-sleeping while a session with the
setting enabled exists. Combine it with the new
“Set Profile Setting” trigger action to keep the
Mac awake only while a long-running job, like an
AI coding agent, runs. It applies only while the
Mac is on AC power. - AI chat can now set a timer to act after a delay
or at a set time (for example, “in two hours,
run this command”). The timer survives restarts,
fires immediately if its time already passed,
and can push your phone when it fires. - You can now press Cmd-F in an AI chat to search
the current conversation. A find bar appears at
the top with match counts and next/previous
navigation; matches are highlighted in place.
Supports smart-case, case-sensitive, and regular
expression modes. - Right-clicking your own message in AI chat now
offers Delete, which removes that message and
everything after it. Edit, Fork, and Delete are
also available when right-clicking inside the
message text. Editing or deleting a message now
also syncs to a paired iPhone viewing the same
chat (and the phone can delete messages too). - New OSC 1337 codes let a program move and resize
its window across monitors using global
coordinates: SetWindowFrame=x;y;w;h sets the
frame, while ReportWindowFrame and
ReportScreenFrames report the current frame and
the layout of all displays. Unlike the older CSI
3;x;y t sequence, these are not limited to the
monitor the window is already on. - The OSC 1337 SetColors escape sequence now
accepts a palette index as the value, written
i:N where N is 0-255. Indices 0-15 follow the
loaded color preset and light or dark mode, so a
program can set the badge or tab color to track
the preset instead of hardcoding an RGB value.
For example, SetColors=badge=i:3 uses ANSI
yellow. Issue 12991. - iTerm2 now answers CSI 16 t (report character
cell size), which was previously ignored. - Each profile can now choose what its bell sounds
like, in Settings > Profiles > Terminal > Bell
sound. Pick one of the sounds installed on your
Mac or choose “Other…” for any audio file of
your own, and give noisy sessions a bell you can
tell apart from the rest. - There is a new “Centered, No Title Bar” window
style, in Settings > Profiles > Window > Style
and in the Window > Window Style menu. It stays
centered on the screen like the Centered style
but has no title bar or window controls, which
is how Centered windows looked before 3.7.0. - Window > Lock Location pins a window to its
current display and position. If anything moves
the window it goes right back, and if its
display is disconnected, as happens while a
laptop sleeps, the window returns when the
display comes back. Dragging the window turns
the lock off. - The built-in web browser can now remember HTTP
basic-auth user names and passwords. Check
“Remember this password” in the sign-in dialog
and it is saved in the browser password manager
(Keychain, 1Password, and the other supported
back ends) and offered automatically next time.
The dialog also has a Password Manager button to
pick an existing login. View or remove saved
passwords from the password manager. - The Compact and Minimal themes, which have no
title bar, can now show the window name beside
the tabs. It appears once you name a window with
Window > Edit Window Title, so windows you
haven’t named look the same as before.
Double-click the name to rename the window. - The workgroup toolbar can show an AI Usage gauge
with your Claude subscription’s session and
weekly usage as progress bars. Add it to a
session’s toolbar in the workgroup settings and
choose how often it refreshes; it reads the
numbers by running the Claude Code command-line
tool. You can point it at your own command
instead. - New variables iterm2.workingSessionCount and
iterm2.waitingSessionCount count the sessions
whose status reads “working” or “waiting”. Use
them anywhere interpolated strings work, such as
badges, tab titles, and the status bar. - New setting in Settings > Appearance > General
shows an iTerm2 icon in the menu bar, optionally
followed by an interpolated string such as
(iterm2.workingSessionCount). - New per-profile setting “Use HDR cursor when
available” in Settings > Profiles > Colors. When
on, a white cursor renders brighter than white
on an HDR-capable display, with either the GPU
or the legacy renderer, and a slider sets how
bright it gets. - Apps can now control right-to-left text
reordering with the BDSM and SCP escape
sequences. CSI 8 l enters explicit mode, in
which text is drawn exactly as written, and CSI
8 h returns to implicit mode. This keeps
full-screen apps that lay out their own text
from being scrambled. CSI 1 SP k and CSI 2 SP k
make reordered lines left-to-right or
right-to-left, and CSI 0 SP k restores the
default. - it2 can now target the session showing a
particular tmux pane. From inside a tmux
Integration pane, pass --tmux "$TMUX"
--tmux-pane "$TMUX_PANE" to session set-status,
get-status, set-var or get-var. This also works
on a host reached through SSH Integration.
Claude Code’s status is no longer reported from
a pane of a plain tmux session (one not using
tmux Integration), since iTerm2 can’t tell which
tab, if any, is showing that pane. - New
it2 session get-statusprints a session’s
status indicator as JSON.set-statusno longer
requires --session and defaults to the active
session. - A session status can now say when it stops being
true. With it2 set-status --expires-on, a
program gives the status to switch to once the
work it describes is over, so a status doesn’t
outlive a program that was stopped partway
through. - The it2 command-line tool can now send a push
notification to your paired companion device
with “it2 notify «title» «body»”.
Improvements:
- A warning that iTerm2 shows while you are away
from your Mac no longer cuts off your paired
iPhone. The iTerm2 Buddy app stays connected,
shows the warning, and lets you answer it,
including “don’t ask again” and password prompts
(a password manager’s master password, an scp
password). Other kinds of dialogs can’t be
answered from the phone, but it now says the Mac
is waiting instead of appearing disconnected.
Requires an up-to-date iTerm2 Buddy. - Pairing a companion iPhone no longer requires
AI. The setup wizard (iTerm2 > Companion Device
Settings) now lets you choose “Terminal viewing
& control only”, so you can browse sessions,
watch live output, and type from your phone
without an AI plugin or API key. Chat features
appear disabled with an explanation until you
turn AI on. - If a profile archives sessions when they close
(Settings > Profiles > Session), Undo Close
keeps working after its time limit runs out.
Archived sessions come back from their archives
in their original split panes, tabs, and
windows. The last 20 can be undone even after
restarting iTerm2. - iTerm2 now recognizes when a terminal is running
on your own Mac by machine identity rather than
by hostname, so features that only work locally
keep working over a VPN or Tailscale connection
where the hostname no longer matches. Reinstall
shell integration (iTerm2 > Install Shell
Integration) to enable this. - The AI timeout is now an idle timeout (the
maximum gap between bytes received) rather than
a cap on the total time a request may take. A
slow but healthy generation, such as a reasoning
model that is still thinking or a cold local
model, keeps running as long as it makes
progress, while a dead connection still fails.
The default was also raised to 20 minutes. This
fixes spurious timeouts against local Ollama
servers. - The manual AI model editor can now set custom
HTTP headers and an API key for each model, so
an authenticated self-hosted or gateway endpoint
gets its own Authorization header and key.
Existing global headers are copied to your saved
models. The new API key setting can send a
provider’s stored key, a key entered just for
that model, or no key. The default, Automatic,
shows which key it picked and sends a provider’s
key only to that provider’s own servers, so a
gateway such as OpenRouter no longer receives
your OpenAI key unless you choose it. Models you
already set up keep sending the key they sent
before. Issues 12975 and 13105. - Hidden hotkey windows no longer redraw at the
full active cadence. Sessions with continuous
output (builds, tails, agents) in a hidden
hotkey window used to burn CPU and GPU rendering
frames nobody could see, spinning fans on
laptops. Hidden windows now drop to the
background refresh rate and repaint the instant
they are revealed. - iTerm2 no longer gradually slows down when left
running for days. The Snippets and Jobs tools no
longer rebuild their lists every time you switch
tabs, and the Script Console now keeps only the
200 most recent finished scripts and API
connections; tools that connect once per
command, like it2, had piled up thousands of
them. - Reduced idle CPU usage that previously grew with
the number of open windows. - Typing in a session connected with SSH
Integration is more responsive on slow or
distant connections. When you typed faster than
the network round trip, each keystroke waited
for the previous one to be acknowledged before
it was sent. Issue 13013. - The session list in iTerm2 Buddy now updates
live as sessions are opened, closed, or renamed
on the Mac. It used to change only when you
reopened it or pulled to refresh. Requires an
up-to-date iTerm2 Buddy. - The built-in AI model list is up to date. It
adds GPT-6 Astra, GPT-6.1 Sol, GPT-6 Sol, GPT-6
Luna, GPT-5.4 Pro, GPT-5.4 mini, Gemini 3.8
Flash, DeepSeek Flash (V4.1), Claude Opus 5.5,
Claude Sonnet 5.5, Claude Fable 5.1, Claude Opus
5, and Claude Fable 5, and the recommended model
for each provider is now its newest flagship.
Newer Claude models also get their full 1
million token context window. o4-mini, which
OpenAI is shutting down, has been removed. - The Move Tab Left and Move Tab Right shortcuts
now move just the current tab instead of its
whole tab group. Moving a tab past the edge of
its group pops it out of the group, and moving
an ungrouped tab into a neighboring group adds
it to that group. - Dragging a tab into a collapsed tab group now
adds it to that group. The group stays collapsed
unless you dragged the active tab, in which case
it expands. While you drag, the group's pill
highlights to show the tab will drop inside it. - Duplicate Tab now opens the copy immediately to
the right of the original tab instead of at the
end of the tab bar. - The password manager’s settings menu now offers
a single choice of how often to ask for Touch ID
or your password: never, once per launch, once
per launch and after the screen locks, or every
time it opens. The new “every time it opens”
option also closes the password manager when the
screen locks. - Focus Follows Mouse no longer takes focus away
from the find bar, composer, or other fields in
a pane by accident. To have focus follow the
pointer onto those views and back onto the
terminal, turn on “…even to views like Search or
Composer” under Settings > Pointer. - Uploads over SSH Integration are about three
times faster. - “Upload and Paste Path” now works in tmux
integration windows reached through SSH
Integration, and before shell integration has
reported a directory (the file goes to your home
directory). Pasted screenshots are converted
from TIFF to PNG and each gets a unique name. - Dropping files into a session connected to a
remote host now offers Paste as Text for a text
file, which types its contents, and “Upload and
Paste Paths” for several files, which uploads
them all and types their remote paths. Local
paths are no longer offered there, since they
name nothing on that host. - AI command suggestions can now recognize when
you have typed a plain-English request instead
of the start of a command. In that case the
suggested command replaces what you typed rather
than being appended to it. - Right-clicking a tab now offers Rename Tab,
which opens the same dialog as double-clicking
the tab. - “it2 session list” takes a --format option: an
interpolated string evaluated in each session,
such as '(session.path)'. It prints one line
per session, or with --json adds the result to
each session as “formatted”. This lists every
session’s directory or job in one command. - Pausing a spinning progress indicator with OSC
9;4;4 now shows a paused spinner: a still yellow
band in the session and a pause symbol in the
tab, instead of a yellow bar at 10%. - The git branch shown in the session toolbar can
now be right-clicked to copy its name, or
dragged into a text field to paste it (or
dropped on the Finder as a text clipping).
Available only while a branch name is showing,
not during a rebase or similar. - The Current Directory status bar component’s
folder icon is now a proxy icon: drag it to
another app to drag the session’s working
directory, like the proxy icon in a window title
bar. - The Interpolated String status bar component now
shows [undef: x] for a reference to an undefined
variable instead of silently rendering an empty
string, making typos and unavailable variables
obvious. Use (x?) to treat a missing value as
empty. - The session’s status is now available in the
session variable “status”, so you can show it in
a badge, title, or status bar component. - AI chat asks for approval less often when it
types a message you dictated into another
session, such as telling a coding agent to
re-run its tests once a watched session
finishes. - The Session Status toolbelt tool now lists
Claude Code sessions running inside tmux
Integration panes on this Mac. They never
appeared before, and their status went
unreported to the rest of the app. Issue 13049. - If you launch a second copy of iTerm2 while one
using the same settings is already running,
iTerm2 now warns you that the two could
overwrite each other’s preferences and saved
windows, and offers to quit and switch to the
running copy or launch anyway. - Browser tabs now show a title (page title, or
the host, or the profile name) instead of a
blank tab and window title. - In the minimal theme, a browser tab's light or
dark appearance and tint now follow the page's
background color instead of the profile's
terminal background color. - The Session Status toolbelt tool has a snooze
button next to the notify button that toggles
snooze on the selected session. - The Companion setup wizard no longer asks for an
API key when AI already works, or when you chose
a model manually in Settings > General > AI. If
that model isn’t set up yet, the wizard says so
and lets you finish setup anyway. - AI command safety checks now use your provider’s
smaller, cheaper model when one is available, so
they cost less. - Settings search now reveals controls that live
on an inner sub-tab (such as AI > Features). It
switches to the correct nested tab before
highlighting, instead of dimming a bogus area. - With the tab bar on the left or right, the tab
and tab group context menus now say “New Tab
Below” and “Close Tabs Below” instead of “to the
Right”, matching the direction the tabs actually
run. The confirmation for closing the tabs after
a group follows suit. - The About window now follows the current theme,
updating live when you switch between light and
dark. On macOS 26 the backer list sits in a
rounded container matching the sponsor cards;
before that, dark themes get a frosted-glass
look.
Bug Fixes:
- Opening an Instant Replay recording no longer
applies settings embedded in the file that could
run commands, such as triggers. Only appearance
settings like colors, fonts, and cursor style
are taken from a recording. Issue 13128. - Answering “Always” to “Close tmux windows after
detaching?” now closes only the tmux integration
windows left open by the detach. It used to
close every session in the app. - Restarting or logging out of macOS while Claude
Code was running in iTerm2 could cause your
windows not to be restored at the next login.
Claude Code’s status updates could relaunch
iTerm2 just as it was quitting, and that brief
launch replaced the saved windows. - After iTerm2 restarted with your sessions
restored, which happens after an update, a
crash, or a force quit, commands in newly opened
sessions could fail to reach devices on your
local network with “No route to host” until you
quit and reopened iTerm2. New sessions now get
local network access right away. Sessions
restored from before the restart still need to
be restarted to regain it. Issue 12106. - When an SSH Integration connection dropped, for
example because the ssh process or the remote
server was killed, the session hung: nothing you
typed afterward appeared. It now ends the
connection and returns to the local shell. - Stopping an AI chat reply while another chat is
also replying no longer cancels the other chat’s
reply, and the stopped reply now really stops
downloading instead of running (and using up
tokens) until it finishes. - Fixed various crashes, including ones when
splitting, maximizing, swapping, or rearranging
panes in a tmux integration window left open
after detaching; when a maximized split pane was
torn down, such as when a tmux connection
dropped; at every launch when a profile’s name
began with a combining character; when a trigger
had no regex, such as an event trigger written
by hand in a Dynamic Profile; when opening a tab
while a paired iPhone was connected; and when
saving a debug log that captured malformed text. - Pressing space twice in a terminal no longer
types a period when macOS’s “Add period with
double-space” setting is on. In vim this could
repeat your last command. Issue 13123. - iTerm2 no longer applies macOS’s automatic
spelling correction, capitalization, or smart
quotes and dashes. They could change what you
type in a terminal. Text replacements still
work. - A tab name you set yourself with Edit Tab Title
is no longer replaced by the session’s name
whenever the tab’s subtitle changes or its label
is otherwise refreshed. Issue 13072. - Make Text Bigger works again when you type it as
⌘⇧= (the way you type ⌘+). Since 3.7.0, that
keystroke opened the Clippings panel instead,
because Show Clippings had claimed it. Show
Clippings is now ⌘⇧⌥G. Issue 13022. - In a session that ssh'ed to another machine
without ssh integration, the current directory
(and paths used by Upload and Paste Path) no
longer switches to the local Mac's directory
after you run a command. - Fixed restored sessions bringing lines that had
been erased by a clear screen back onto the
screen, above the newer output, with the cursor
in the wrong place. - On macOS 26, hiding a hotkey window when no
other iTerm2 windows are open once again returns
focus to the previous app right away, so text
you type immediately afterward no longer goes to
the terminal. - A hotkey window set to appear on all spaces no
longer switches you to a different space when
revealed after reopening the laptop lid. It is
now revealed on the current space and rejoins
all spaces afterward. Issue 12968. - Fixed a bug on macOS 26 where pressing Cmd-Tab
to leave a full screen window would flash the
other app and immediately switch back to iTerm2,
sometimes leaving you unable to change apps
without the mouse. This affected windows using
the older full screen style with menu bar hiding
turned on. - Fixed dark triangles that could appear over a
background image near the edges of the window
with the GPU renderer. Issues 12604, 12642,
12653, and 12791. - Fixed “Remember my choice” applying the wrong
choice in the dialog that appears when you paste
a file, folder, or image. The answer was
remembered by button position, so a choice saved
for a local file could turn into a different
button in a session connected to a remote host,
silently uploading the file. Each set of choices
the dialog can offer now remembers its own
answer, so silencing it for a local file no
longer silences it for a folder or for a remote
host. Any answer you had saved for these dialogs
is cleared, so you will be asked once more. - Fixed a beachball with a large file, or a file
on a slow disk, copied from the Finder. Opening
Edit > Paste Special read the whole file just to
decide whether to enable Paste File
Base64-Encoded, and pasting read it again before
offering the paste options. Opening the menu no
longer reads the file at all, and pasting looks
at only the first 50 KB. - Fixed a lag when switching or closing tabs with
the Session Status tool open in the toolbelt. - Fixed a rare race where a new session could
silently lose some of the first output it
received. - Attaching with tmux Integration no longer fails
when a pane’s scrollback contains raw tmux
control-mode text (for example, from an earlier
tmux -CC run in a terminal without tmux
Integration). iTerm2 mistook a %exit line in
that text for tmux quitting, dropped the
connection, and printed the rest of the protocol
stream into the window. - In a tmux Integration session, keys such as
Shift-Enter are now encoded the same way before
and after a detach and reattach. Previously a
pane could encode them one way before a detach
and another way after, until the app was
restarted. With tmux 3.9 or later, iTerm2 lets
tmux encode each key for the app in the pane,
including with the Kitty keyboard protocol.
Issue 13076. - In tmux integration, a layout change made by
tmux while Find > Filter was open on a pane no
longer hides pane %0 or moves keyboard focus
away from the filtered pane. - In tmux integration, a new pane now picks up
user variables, the host name, the current
directory, and the shell integration version
that its shell reported while the pane was
opening. Before, a badge such as
(user.host_name) stayed empty until the shell
showed its second prompt. Issue 13006. - In tmux integration, a window created with “tmux
new-window -d” now opens as a background tab
instead of taking focus. Issue 13112. - In tmux integration, iTerm2 could miss a window
switch made by another tmux client or a script
and keep showing the old tab. - Fixed tmux integration sessions not resizing
when the tab bar hides or shows in full screen
with “Hide tab bar when there is only one tab”
enabled, which left an unused strip at the
bottom of the window. - When a tmux integration window first loads, its
tab now shows the finished title right away. It
used to briefly show a bare session name, with
neither the “↣” marker that flags a tmux tab nor
the second line under the title. - Stopped falsely offering to reset the key
reporting mode after every command when your
shell turns the keyboard protocol on and off
itself, as Fish 4 does. 3.7.2 fixed this only
for Fish with oh-my-posh. - Fixed slow replies to cursor-position and other
status queries. Programs that send many of them
at once, such as during capability detection,
waited seconds instead of milliseconds. Issue - Fixed Kitty keyboard protocol base-layout
reporting for non-US keyboard layouts, so
shortcuts such as Ctrl-C work without switching
layouts. - With “Report keys using CSI u” turned on and
Option set to Meta, pressing Option with a
letter sent nothing at all. It now sends the
meta byte, as it does without CSI u. - Fixed a bug in the Kitty graphics protocol where
transmitting a new image under an image id
already in use kept drawing the old image in
places that already showed it. The newest image
is now used, so one program no longer shows a
stale picture left behind by another. - Fixed double-width and double-height lines (ESC
#3, #4, and #6) staying double-sized after the
line was erased, scrolled, or reset. A
full-screen program that drew a large title and
then repainted could show giant, clipped text
where ordinary text belonged; entering the
alternate screen, Reset, and Clear Buffer left
the same stale lines behind. - Fixed a bug where a subparameter written after a
full CSI parameter list was dropped even though
it belonged to a parameter that had been stored,
such as the 16th. - Parentheses, brackets, and other mirrored
punctuation now draw correctly in right-to-left
text, so a bracket around an English phrase
inside Persian is handled the same way as in
other Mac apps. - Fixed misplaced letters in right-to-left words
that combine a lam-alef ligature with a
diacritic, such as the Persian کاملاً and لطفاً. - Selecting part of an Arabic or Persian word no
longer breaks its cursive joining. Previously a
selection or a mid-word color change split the
word (for example کامل) into isolated letter
forms. Arabic and Persian text could also
sometimes be drawn with each letter in its
isolated form. - Selecting text on right-to-left lines now
selects exactly the columns you drag over.
Previously dragging over a line with embedded
English or digits could highlight a different,
broken-up span, and the first or last character
of a right-justified line could be impossible to
select. Copied text stays in reading order, and
extending a selection across several lines
follows each line’s direction. - Fixed a bug where a skin-toned emoji like 👍🏻 was
drawn as the plain emoji followed by a separate
color swatch, taking four columns. It is now a
single emoji two columns wide, matching other
modern terminals. - Fixed text written over the right half of an
emoji or other wide character landing two
columns too far to the right, a bug introduced
in 3.7.3. - Find now matches text that spans a blank the
program created by moving the cursor instead of
printing a space. Before, searching for more
than one word could fail on output from programs
like Claude Code even though the words appeared
separated by a space. - Resizing a window while only blank space was
selected left behind an empty selection, so Copy
and other selection-based actions acted on
nothing. - Fixed a bug where a smooth-blinking cursor
passed through light gray while fading in the
GPU renderer instead of fading directly to the
background color. Issue 12990. - The “Animate large movements” cursor option no
longer streaks a cursor-colored trail across the
screen when a program such as top redraws the
screen on its own. The animation now plays only
for cursor jumps that follow your own keyboard
or mouse input. - Fixed a bug where pressing Backspace while
composing text with an input method such as the
Korean 2-Set IME could leave a stray character
behind that needed an extra press to remove.
Pressing Return while composing now submits the
line in one press. - Voice Control corrections, like “replace world
with there”, now replace the text when it ends
at the cursor. Before, the new text was typed
after the original. Corrections elsewhere, or in
full-screen apps like vim, are ignored. - Key bindings saved by older versions of iTerm2
no longer fire when you press A while “Interpret
key bindings based on physical key” is on, which
could make the A key stop typing. iTerm2 also no
longer mistakenly offers to turn that setting on
when you press a shortcut like ^A. Issue 13110. - Pasting or dropping a file or image into a local
tab no longer offers only to upload it when
another tool in your shell (such as oh-my-zsh)
reports an outdated host name. When iTerm2 can’t
be sure a session is on another machine, the
paste dialog now also offers to paste a local
path. Issue 13117. - Dragging a file into a local terminal now
inserts its path on VPN or Tailscale networks,
where it had instead been offering to upload the
file. Requires the updated shell integration
scripts. - The file paste options (Paste Path, Paste Paths,
and Upload and Paste Path) now honor the “When
pasting a dropped filename, wrap it in double
quotes” setting in Settings > General, as
dragging a file into a local session already
did. They previously always used double quotes,
ignoring the setting. - Uploading several files at once to a host you
connected to with an SSH profile now uploads all
of them instead of only the first, and “Upload
and Paste Path” now pastes the path the file
actually landed on, such as “name (2)” when the
name was taken. - Cancelling an upload over SSH Integration no
longer leaves a temporary file on the remote
host or gets stuck at “Cancelling…”. - Archive Sessions on Closure failed with “The
file doesn’t exist” whenever the session’s name
contained a slash, such as a title that shows
the tty or the current directory. Slashes and
other characters that can’t appear in a filename
are now replaced with underscores, the time in
the archive name uses periods instead of colons
so Finder shows it correctly, and very long
names are shortened to fit. The same cleanup now
applies to automatic session log filenames,
which used to handle only slashes. Issue 13094. - Archive Sessions on Closure failed with “the
volume is read only” when no archive folder was
chosen. Turning it on with an empty folder now
fills in ~/Library/Application Support/
iTerm2/Archives, and profiles that already have
it on with no folder archive there. Issue 12980. - Fixed several bugs archiving sessions on close:
sessions closed shortly before quitting are now
always archived, split panes with the same name
are all saved instead of only one, and closing
an archived session soon after opening it no
longer warns that it ended very soon after
starting. - Duplicate Session no longer stamps a permanent
working-directory override on the new session.
Previously that override was inherited by split
panes and could go stale, dropping the shell in
the wrong directory. Issue 12981. - The SCP-download menu animation no longer leaves
an invisible window behind. That stray window
could make clicking the Dock icon fail to open a
new terminal after the last window was closed.
Issue 12996. - Fixed a bug where auto-update failed with a
message about running from the download location
when iTerm2 was unzipped or moved to an
Applications folder from the command line rather
than dragged in the Finder. iTerm2 now fixes
this automatically on launch, and no longer
offers to move a copy that is already installed. - Semantic History can once again open files in
Xcode when the active developer directory is set
to the Command Line Tools rather than Xcode. It
used to show a “Semantic History Command Failed”
alert instead. Issue 13091. - Semantic History could open the wrong file in
TextMate, BBEdit or MacVim when the path
contained a space, an ampersand, or another
character with special meaning in a URL. - “Open Selection as URL” now works on links
without a scheme, like example.com/path, and is
hidden when the selection isn’t a URL. Issue - Opening a link from a browser tab in a tab group
(with “Open in New Tab” or a link that wants a
new window) now puts the new tab in that group
instead of leaving it ungrouped in the middle of
the group. Issue 13029. - The control sequence that resets a tab color to
the profile’s now restores it correctly when the
profile has separate light and dark mode colors
but its Tab Color predates them. It could leave
the tab with a color you never chose, often red,
or with no color at all. Issue 13058. - On macOS Tahoe, the progress ring around a tab
is no longer missing from the first and last
tabs. A tab that is partly scrolled under the
window controls now keeps the visible part of
its ring, and its activity indicator, instead of
losing them entirely, and the ring stays within
the tab bar’s rounded ends rather than drawing
past them. - Fixed the git ahead/behind arrows in the session
toolbar and the git status bar component staying
black in dark mode instead of matching the text
color. - On macOS 26, Maximized and Centered windows, and
windows opened for a single command, now show
the tab bar in the title bar like ordinary
windows. Before, their tab bar background didn’t
match the title bar and a dark line separated
the two. - With the Minimal or Compact theme and no tab bar
at the top, the window buttons that appear when
you point at the window’s top-left corner no
longer cover a status bar or Workgroup toolbar
there, which made its leftmost item impossible
to click. A small arrow appears in the corner
instead; click it to show or hide the window
buttons. Issue 13100. - Fixed a bug where dismissing a confirmation in
certain Settings sheets (such as the hotkey
configuration or special exceptions panels)
would bring a terminal window to the front. - Help popovers, such as the ones shown by the ?
buttons in Settings, no longer cut off the last
line of text, and no longer scroll when all of
their text fits. - Choosing Show Session Note from Open Quickly no
longer makes an empty note appear briefly and
then disappear. - When no valid UNIX locale matches your language
and region, the prompt to pick one now appears
as a normal dialog the first time a hotkey
window opens, instead of as a sheet on the
not-yet-visible window. - Pairing a companion iPhone no longer fails on
networks that can’t reach iterm2.com. iTerm2 now
falls back to a second copy of its list of relay
servers. If neither can be reached, the pairing
window says so and suggests another network;
hover over the message to see what went wrong
with each server. - Installing the Claude Code Integration now
honors $CLAUDE_CONFIG_DIR, so per-tab status
indicators work for people who keep Claude
Code’s settings outside ~/.claude. It used to
always install into ~/.claude, where Claude Code
never looked. - The Claude Code Integration no longer warns that
it looks broken when you edit its hook commands
in Claude Code’s settings.json to use $HOME, ~,
or a wrapper script. Reinstalling also keeps
such commands instead of adding a second copy of
each hook. Uninstalling and reinstalling leave a
hook command alone if it runs other programs
besides the integration’s, and Uninstall lists
any it left in place. - Installing, reinstalling, or uninstalling the
Claude Code Integration now updates the file
that a symlinked settings.json points to (as
with GNU Stow or chezmoi). It used to replace
the link with a plain file that other users
could read, and Claude Code stopped seeing edits
to the original file. Issue 13098. - Installing or reinstalling the Claude Code
Integration no longer replaces a Claude Code
settings.json that has a syntax error, which
erased all your other Claude Code settings. It
now leaves the file alone and asks you to fix it
and try again. - Interrupting Claude Code with Esc now returns
its Session Status to idle. The tab kept reading
“working”, or “waiting” if a permission prompt
was open, until the next thing you asked it to
do. - Fixed Notify on Status Change reporting that a
session changed from “none” when its status had
already been set before you turned the
notification on, and alerting when a program
merely restated that status. - Debug logs no longer include passwords sent to
or received from password manager integrations,
AI API keys, or the values of custom HTTP
headers configured for AI models. - The Bitwarden, KeePassXC, and Keeper password
manager integrations now find command-line tools
installed by Homebrew or npm on your shell’s
search path instead of asking you to locate
them. Homebrew’s “bw” is a script that needs
“node”, which the integration could not find, so
Bitwarden showed an empty list. - If you choose a password manager’s command-line
tool by hand, iTerm2 now keeps the path you
chose, so it keeps working after the tool is
upgraded. - The Bitwarden integration now reports the
Bitwarden CLI’s own error message instead of
showing an empty list or a vague error. It lists
only items in a Bitwarden folder named “iTerm2”,
and now says so when your vault has no such
folder. - Fixed a bug where the manual AI model editor’s
Test Connection button ignored the “Supports
temperature” toggle and always sent the
temperature parameter, failing against endpoints
that reject it. - Fixed a bug where the manual AI model editor’s
Test Connection button sent your API key to an
AI endpoint on your local network, even though
real requests never do. An authenticated local
server could report a successful test and then
reject every chat message. The test now behaves
like a real request, and names the header to add
under Custom headers when the endpoint wants a
credential. - Fixed a bug where the host in an AI model’s URL
was compared letter for letter, so its
capitalization, or a trailing dot, changed which
API key was sent. A local address written that
way (http://LOCALHOST:11434) was not recognized
as self-hosted and received your provider key,
and a provider address written that way
(https://API.DEEPSEEK.COM) was attributed to
OpenAI, so the wrong key was sent. - Fixed a bug where a custom HTTP header for AI
requests whose name differed only in
capitalization from one iTerm2 sets itself (such
as “authorization”) was sent alongside the
built-in header instead of replacing it, leaving
it to chance which value the server saw. - Fixed a bug where turning on “Use AI to check
safety of commands before running them” on a Mac
without Apple Intelligence was turned back off
the first time you used AI chat, which then
asked whether to enable it again. - Fixed a bug where, if the AI asked for
permission in a chat and you ignored the request
and sent a new message instead, the chat would
stop responding. - Fixed a bug where an AI chat permission button
(such as “Check terminal state”) did not update
its label after you changed the setting, and
re-clicking it appeared to do nothing. - Fixed a bug where quitting iTerm2 unlinked each
AI chat from its terminal session and canceled
what the chat was watching, even when window
restoration brought the session back at the next
launch. - Editing an AI chat message now fully removes the
later conversation, so the AI no longer sees
leftover command results from it. - Stopping an AI chat reply and then sending
another message no longer shows the rest of the
stopped reply as the answer to the new message. - The API key field shown when AI features ask for
a key now hides what you type instead of showing
the key in plain text. - In AI chat, a reply that mentions two or more
sessions now shows each one as a clickable link.
Before, the session names could turn into plain,
unclickable text. - Fixed Tab.async_update_layout in the Python API
making a browser pane many times too big,
squeezing the panes beside it, and sometimes
crashing iTerm2. - Fixed the Python API reporting an underline
color, block ID, or hyperlink on cells that did
not have one. The first plain cell after a run
that had one was described as part of that run,
so scripts reading screen contents saw the
attribute spill past where it ended. - iTerm2 writes to disk less often while it saves
window state for restoration. A setting meant to
reduce this had been silently turned off. - Installing shell integration without internet
access now works on systems that don’t have
bash, such as FreeBSD and OpenBSD. Before, the
installer could hang partway through writing the
script when your shell was tcsh. - The Internet-Free Install of shell integration
no longer gets stuck at its first step for zsh
users, and that step can no longer be skipped,
which made later steps write to an invalid path.
Notes 3.7.7beta1 (`docs/notes-3.7.7beta1.txt`)
Version 3.7.7beta1 of iTerm2 was built on October
16, 2026.
New Features:
- Floating Panes: a pane that floats over a tab’s
split panes. Create one with Shell > New
Floating Pane with Current Profile, or with a
key binding or pointer action. Drag its title
bar to move it and its edges to resize it. Hold
Control while dragging to dock it into the split
panes or move it to another tab or window.
Window > Floating Panes can raise, dock, move,
and hide them. They are saved with window
arrangements. tmux integration shows tmux’s own
floating panes (tmux 3.7 and later; moving,
resizing, raising, and docking them needs tmux
3.8). - Support for the Program Status Protocol (OSC
7501), which lets a program report that it is
working, waiting on you, done, or failed. The
tab shows the most urgent status the program
reported, along with its message and progress. A
program reporting on several tasks at once gets
them listed under its session in the Session
Status tool, and each task that needs you can
post its own notification. - Session Status has new “done” and “error”
statuses for work that finished or failed while
you were away. They sort just below “waiting”
and above “working”, and “idle” now sorts last.
Pressing a key in the session marks the result
as seen and turns it idle. Claude Code and Codex
now report a finished turn as “done” and an API
error as “error”. - iTerm2 can now warn you while you are away from
your Mac without cutting off your paired iPhone.
The iTerm2 Buddy app stays connected, shows the
warning, and lets you answer it, including
“don’t ask again” and password prompts. Other
kinds of dialogs can’t be answered from the
phone, but it now says the Mac is waiting
instead of appearing disconnected. Requires an
up-to-date iTerm2 Buddy. - Passwords saved in the Password Manager’s
Keychain storage can now be exported to a
passphrase-encrypted .iterm2passwords file and
imported on another Mac. Use Export Passwords…
and Import Passwords… in the Password Manager’s
settings menu, or double-click the file.
Settings > General > Export All Settings and
Data can also include them. - Each profile can now choose its bell sound in
Settings > Profiles > Terminal > Bell sound.
Pick a sound installed on your Mac or choose
“Other…” for any audio file. - If a profile archives sessions when they close
(Settings > Profiles > Session), Undo Close
keeps working after its time limit runs out.
Archived sessions come back in their original
split panes, tabs, and windows. The last 20 can
be undone even after restarting iTerm2. - New setting in Settings > Appearance > General
shows an iTerm2 icon in the menu bar, optionally
followed by an interpolated string such as
(iterm2.workingSessionCount). - New variables iterm2.workingSessionCount and
iterm2.waitingSessionCount count the sessions
whose status is “working” or “waiting”. Use them
anywhere interpolated strings work, such as
badges, tab titles, and the status bar. - nushell is now supported by shell integration,
including automatic loading and SSH integration. - Apps can now control right-to-left text
reordering with the BDSM and SCP escape
sequences. CSI 8 l enters explicit mode, in
which text is drawn exactly as written, and CSI
8 h returns to implicit mode. CSI 1 SP k and CSI
2 SP k make reordered lines left-to-right or
right-to-left, and CSI 0 SP k restores the
default. - You can hide a browser session’s toolbar so the
page fills the pane with Web > Show Toolbar. - Right-clicking a tab now offers Rename Tab.
- “it2 session list” takes a --format option: an
interpolated string evaluated in each session,
such as '(session.path)'.
Other Improvements:
- The built-in AI model list is up to date. It
adds GPT-6 Astra, GPT-6.1 Sol, GPT-6 Sol, GPT-6
Luna, GPT-5.4 Pro, GPT-5.4 mini, Gemini 3.8
Flash, DeepSeek Flash (V4.1), Claude Opus 5.5,
Claude Sonnet 5.5, Claude Fable 5.1, Claude Opus
5, and Claude Fable 5, and the recommended model
for each provider is now its newest flagship.
Newer Claude models also get their full 1
million token context window. o4-mini, which
OpenAI is shutting down, has been removed. - The manual AI model editor has a new API key
setting. It can send one provider’s stored key,
a key entered just for that model, or no key.
Automatic, the default, sends a provider’s key
only to that provider’s own servers, so a
gateway such as OpenRouter no longer gets your
OpenAI key unless you choose it. Models you
already set up keep sending the key they sent
before. Issue 13105. - Uploads over SSH Integration are about three
times faster. - “Upload and Paste Path” now works in tmux
integration windows reached through SSH
Integration, and before shell integration has
reported a directory, in which case the file
goes to your home directory. Pasted screenshots
are converted from TIFF to PNG and each one gets
a unique name. - Focus Follows Mouse no longer takes focus away
from the find bar, composer, or other fields in
a pane by accident. To have focus follow the
pointer onto those views, turn on “…even to
views like Search or Composer” under Settings >
Pointer. - “Support basic HTML tags in tab titles” now
applies to pane title bars too. The window title
shows the text without the tags. - Pausing a spinning progress indicator with OSC
9;4;4 now shows a paused spinner instead of a
yellow bar at 10%. - iTerm2 no longer applies macOS’s automatic
spelling correction, capitalization, smart
quotes and dashes, or “Add period with
double-space”. They could change what you type
in a terminal. Text replacements still work.
Issue 13123. - With the M