# Technical Documentation: gnachman/iTerm2

> ℹ️ **Provenance:** Multi-Tier Ingestion · [CodeWiki Reference](https://codewiki.google/github.com/gnachman/iTerm2)

### Overview (README.md)

<div align="center">

# iTerm2

### macOS Terminal Replacement




**[Website](https://iterm2.com)** • **[Downloads](https://iterm2.com/downloads.html)** • **[Documentation](https://iterm2.com/documentation.html)** • **[Features](https://iterm2.com/features.html)**

</div>

---

## 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](https://iterm2.com/downloads.html)

For the bleeding edge without building, try the [nightly build](https://iterm2.com/nightly/latest).

### Build from Source

> **Note:** Development builds may be less stable than official releases.

#### Prerequisites

- No manual prerequisites. `make setup` will install [Homebrew](https://brew.sh/), Xcode,
  Rust, and all other dependencies, prompting for confirmation before each privileged step.

#### Clone

```bash
git clone https://github.com/gnachman/iTerm2.git
```

#### Setup (first time)

```bash
make setup
```

`make setup` is interactive and will prompt for confirmation before any privileged or
security-sensitive operation. It performs the following steps:

- **Homebrew** -- Installs [Homebrew](https://brew.sh/) 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](https://github.com/XcodesOrg/xcodes) (via Homebrew) and
  [aria2](https://aria2.github.io/) (for faster downloads), then either selects an
  existing `/Applications/Xcode*.app` or downloads the latest Xcode automatically.
  Prompts before running `sudo xcode-select` and before accepting the Xcode license.
- **Rust** -- Installs [rustup](https://rustup.rs) 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. If `brew link python@3` would overwrite existing symlinks, prompts for
  confirmation before proceeding.
- **SF Symbols** -- Installs the SF Symbols cask (a `.pkg` installer 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:

```bash
make paranoid-deps   # compile OpenSSL, libsixel, libgit2, Sparkle, etc. (sandboxed)
make                 # build iTerm2
```

Re-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:

```bash
git update-index --skip-worktree last-xcode-version
```

To undo: `git update-index --no-skip-worktree last-xcode-version`

#### Build

```bash
make Development
```

#### Run

```bash
make run
```

#### Architecture

Builds target your native architecture by default. To produce a universal (arm64 + x86_64) binary:

```bash
UNIVERSAL=1 make Development
```

#### Code signing

Code signing is disabled by default to keep contributor builds simple. To enable it with the project's signing identity:

```bash
SIGNED=1 make Development
```

#### Building in Xcode

If you prefer building from Xcode instead of the command line:

1. Complete the **Clone** and **Setup** steps above.
2. Configure code signing with your team ID:

   ```bash
   tools/set_team_id.sh YOUR_TEAM_ID
   ```

   This script updates `DEVELOPMENT_TEAM` in 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.

3. Open `iTerm2.xcodeproj` in Xcode.
4. Edit Scheme (Cmd-<) and set Build Configuration to **Development**.
5. Press Cmd-R to build and run.

---

## Development

### Contributing

We welcome contributions! Please read our [contribution guide](https://gitlab.com/gnachman/iterm2/-/wikis/How-to-Contribute) before submitting pull requests.

---

## Bug Reports & Issues

- **File bugs:** [iterm2.com/bugs](https://iterm2.com/bugs)
- **Issue tracker:** [GitLab Issues](https://gitlab.com/gnachman/iterm2/issues)

> **Note:** We use GitLab for issues because it provides better support for attachments.

---

## Resources

| Resource         | Link                                                              |
| ---------------- | ----------------------------------------------------------------- |
| Official Website | [iterm2.com](https://iterm2.com)                                  |
| Documentation    | [iterm2.com/documentation](https://iterm2.com/documentation.html) |
| Community        | [iTerm2 Discussions](https://gitlab.com/gnachman/iterm2/-/issues) |
| Downloads        | [iterm2.com/downloads](https://iterm2.com/downloads.html)         |

---

## License

iTerm2 is distributed under the [GPLv3](LICENSE) license.

---

## Support

If you love iTerm2, consider:

- Starring this repository
- Spreading the word
- [Sponsoring development](https://iterm2.com/donate.html)

---

<div align="center">

**Made by [George Nachman](https://github.com/gnachman) and [contributors](https://github.com/gnachman/iTerm2/graphs/contributors)**

</div>

---

### 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-integration` submodule (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.

## Bundling the script

- [ ] `tools/copy_shell_integration.sh` - add a `cp` line copying
      `$SUBMODULE/shell_integration/<shell>` to
      `Resources/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` - add
      `iterm2_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). Use `tools/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`,
      and `copy_shell_integration.sh` regenerates it by grepping
      `ShellIntegrationVersion=` out of every bundled
      `Resources/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 `@2` today, not the
      `@1` its 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. contain
      `ShellIntegrationVersion=<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`
      - add `iTermShellIntegrationShellXxx` to the
      `iTermShellIntegrationShell` enum.
      (Note: the version header `iTermLatestVersionByShell.h` is NOT
      edited here - it is generated. See "Bundling the script".)
- [ ] `sources/ShellIntegrationInstaller/iTermShellIntegrationWindowController.m`
      - (a) `iTermShellIntegrationShellString`: return `@"<shell>"`; (b)
      the shell-name to enum `map` (~line 610): add
      `@"<shell>": @(iTermShellIntegrationShellXxx)`.

## Shell version-notification table

- [ ] `sources/VT100Screen/VT100ScreenMutableState+TerminalDelegate.m`
      `terminalSetShellIntegrationVersion:` - 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)
      add `local("iterm2_shell_integration.<shell>")` to the resource
      list (~line 167); (b) add `case xxx = "<shell>"` to the `Shell`
      enum and its factory switch; (c) add an
      `XxxShellIntegrationInjection` class that sets the shell's
      marker/config env var so the shell autoloads the script (xonsh
      appends the script path to `XONSHRC`; 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
      return `argv` UNCHANGED (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 return `nil` in the
    alias builder's `case ...Xxx:`.
  - `shell_and`/quoting switch (~line 331): add `case ...Xxx:` in the
    arm matching the shell's `&&`/quoting behavior.
  - if autoload: `mkdir -p <autoload dir>` step (xonsh added
    `mkdir -p ~/.config/xonsh/rc.d`) in both the string builder
    (~line 413) and the `reallySend` path (~line 516).
  - `launchBashString` / `exitBashString` single-bash special case
    (only if the shell needs the same prompt-setup workaround xonsh
    did).
  - `modifyStartupScriptsAndProceedTo:` early-return no-op + the two
    `finishSendShellCommandsInstall` shortcuts (~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 the `isDone` shortcut (xonsh: ~lines 64, 131).

## Other shell-list touchpoints

- [ ] `sources/API/iTermAPIScriptLauncher.m` - add `@"<shell>"` to
      `knownShells` (~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 `shells` array (~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.
- [ ] `OtherResources/framer.py` - `get_env_var` (~line 596) detects
      the shell from `os.environ.get('SHELL')`. Two edits:
  - add `<shell>` to `known_shells` (~line 604).
  - add a `$SHELL` override mirroring the xonsh one (~lines 601-602),
    keyed off the shell's marker env var:

          if 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`), so
    `basename($SHELL)` misdetects. xonsh needed this override (keyed off
    `XONSHRC`) for exactly this reason. Verify by launching the shell as
    a non-login custom command and confirming framer reports
    `shell=<shell>`.
- [ ] `sources/AITerm/RemoteCommand.swift` - update the `getShellType`
      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 the `SupportedShell` enum (xonsh touched five):
  - the enum `case` list (~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 use `source <path>`; xonsh uses
    `execx(open('<path>').read())`; others differ - watch for shells
    where `source` requires 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 currently
    `XCTSkip`ped for this, so a new such shell likely needs the same
    skip or a CPR responder.
- [ ] `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.m` has 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:

1. **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 to `sources/Localizable.xcstrings`, which is not tracked
   by git. Edit the `xcstrings/` copy, or your changes will not be in your pull
   request.)

2. **XIB strings** - about 70 per-window/per-view catalogs, each named after its
   XIB and living in a sibling `mul.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's
   `comment` tells you the class, the English text, and the object ID. There is
   also `sources/InfoPlist.xcstrings` for Info.plist strings (permission-prompt
   descriptions, app name); see `docs/info-plist-localization.md` for how those
   overlay onto the base Info.plist at runtime.

Every entry stores the English under `localizations.en`; you add a sibling
`localizations.<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.sh` produces 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 the
`mul.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:

```json
"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 `state` to `translated`.** (`new` means 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 is

  ```
  "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 `%ld` to
  `%@` (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: `other` only.
- 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:

```json
"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 (`%lu` here) 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 `one` could 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):

```json
"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**, the `comment`, and
  `extractionState`. Only add/edit your own language's `localizations.<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.xcstrings
```

It must succeed. For a plural entry you can confirm the rule was produced:

```
plutil -p /tmp/loc-check/<lang>.lproj/Localizable.stringsdict
```

You 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.lproj` catalogs.
- 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 named
`repl_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 English
`repl_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.xcstrings` is the largest; the
  `mul.lproj` catalogs 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.
- **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:

1. **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()` called `conversation.stop()` →
     `controller.cancel()`, which set state to `.ground` *silently* and
     never fired the delegate callback. The completion chain from
     `AITermController` → `AIConversation.delegate` →
     `ChatAgent.fetchCompletion` → `ChatService.finishTurn` died at the
     first link. The queue head pointer (A) was never popped; the
     `enqueue(B)` saw `queue.count == 2` and skipped `startNextTurn`.
   - Patched in `ChatAgent.stop()` by routing through
     `conversation.cancelOutstandingOperation()` instead. Pinned by
     `test_chat_stopThenSend_secondMessageStillDispatches`.

2. **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)`
   sets `state = .ground` after dispatching the function call, so
   `handle(.cancel)` and `handle(.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 firing
     `delegate.aitermControllerDidCancelOutstandingRequest(controller)`
     when `delegate.busy` is still true after the state machine call.
     Pinned by `test_chat_orphanToolResponseAfterStop_doesNotOrphanQueuedMessage`.

3. **Orphan tool response silently cancels the next queued turn.**
   After (1) and (2) are fixed, the in-flight `PTYSession` command
   eventually finishes and publishes a `.remoteCommandResponse`. The
   dispatcher that was supposed to consume it has been cancelled, so
   `handleRemoteCommandResponse` returns false. The code fell through
   to `fetchCompletionForRegularMessage`, which called
   `conversation.complete → prepare → cancel()` — the *silent* variant
   that sets `delegate.completion = noop`. The actively in-flight
   queued turn (B) had its completion silently overwritten and never
   fired `finishTurn`.
   - Patched in `ChatAgent.fetchCompletion` by dropping orphan
     `.remoteCommandResponse` messages with `completion(nil); return`
     instead of falling through. Pinned by the same test as (2).

4. **Auto-approved `tool_use` not persisted to chat DB.**
   `ChatClient.processRemoteCommandRequest` returns `nil` from the
   broker processor on the `.always` permission path.
   `ChatBroker.publish` interprets `nil` as a full squelch — skips both
   subscriber delivery *and* `listModel.append`. The `tool_use` record
   carrying the LLM's `function_call` (with its `toolu_…` id) never
   lands in the DB. For the current turn that's fine because
   `AIConversation.messages` is in-memory; but when the next turn
   dequeues from the queue, `ChatAgent.load(messages: history)`
   reconstructs from DB, sees an orphan `tool_result` with no
   preceding `tool_use`, and Anthropic 400s.
   - Patched in `ChatAgent.translate` by synthesizing the missing
     `tool_use` whenever a `tool_result` references a request id that
     wasn't persisted. Pinned by
     `test_chat_squelchedToolUseHistoryDoesNotBreakNextTurn`.

5. **`dropLast()` assumes the wrong invariant.**
   `ChatService.startNextTurn` builds B's history as
   `Array(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 is `A_reply` — `dropLast` drops it. So B's history is
   missing A's full turn outcome (A_reply), and B itself is included
   in history (then re-added via `conversation.add(userAIMessage)`
   in `fetchCompletionForRegularMessage`, duplicating it on the wire).
   - **Not yet fixed.** The current `translate` + `enforceToolUseAdjacency`
     hacks paper over the worst symptoms but the structural assumption
     is wrong.

## 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()` in
`prepare` (AIConversation.swift:222), and the previous
`AIConversation.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 = nil` in `cancel()` could orphan a partial
  streaming reply.
- `delegate.createVectorStoreCompletion = nil` etc. in `cancel()` 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 every
`fetchCompletion` call (`ChatAgent.load(messages: history)`). That
reconstruction has accumulated workarounds:

- `enforceToolUseAdjacency` in `AnthropicRequestBuilder` reorders
  tool_use/tool_result pairs.
- `translate` in `ChatAgent` synthesizes orphan `function_output`
  fillers (line 340-356, for tool_use without tool_result, "iTerm2
  quit mid-tool-call").
- `translate` now also synthesizes orphan `function_call` fillers
  (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 `.ground` state: if
  `delegate.busy` (or `cancellation != nil`), `.cancel` fires
  `aitermControllerDidCancelOutstandingRequest` and `.error` fires
  `didFailWithError`. `.ground` stops being a black hole.

- `AITermController.cancel()` (the silent one used by
  `AIConversation.cancel()`) gets renamed to `tearDown()` and its
  contract documented as "no callbacks; only for teardown after the
  outer caller has already collected what it needs." All current
  callers audited: the `prepare()` site is the suspect one — it's
  used to clean up before starting a fresh `conversation.complete`,
  but the silent overwrite of `delegate.completion = noop` orphans
  any genuinely-in-flight prior call. Replace with
  `cancelOutstandingOperation()` 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 use
  `cancelOutstandingOperation()`. 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 a
`function_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.publish` for
  user-typed messages.
- `startNextTurn` calls `broker.publish` on 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).
- `translate` doesn't need orphan-synthesis hacks; the chat history
  matches LLM-required ordering by construction.
- `enforceToolUseAdjacency` becomes unnecessary.
- The `ChatClient.processRemoteCommandRequest` squelch-vs-persist
  conflict goes away: `ChatAgent` itself creates and persists the
  `.remoteCommandRequest` at 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 an
`async` 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 invoking
`tools/run_ai_live.sh`, which forwards it into the harness config as
`CASSETTE_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 with `record`.

- **`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 begins `CASSETTE 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 `record` mode 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 `record` overwrites 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 show
`elapsed` 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: the `Authorization` /
  `x-api-key` headers and Gemini's `?key=` URL parameter. Two developers
  with different keys hash identically.
- **UUIDs.** Every UUID is replaced with a positional placeholder
  (`<UUID-0>`, `<UUID-1>`, ...) 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 the `Content-Type` header (`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 a
`replay` 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 with
`ITERM2_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:

```sh
# 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 smoke
```

Refresh after changing a request builder:

```sh
ITERM2_AI_LIVE_CASSETTE_MODE=record tools/run_ai_live.sh attachmentMatrix
git diff ModernTests/Resources/AICassettes   # review before committing
```

### Running `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:

```sh
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.sh
```

## How 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 in `request`).
- Cassette logic: `AILiveHarness/AICassette.swift` (`AICassetteCanonicalizer`,
  `AICassetteStore`, `AICassetteSession`).
- Wiring: `AILiveHarness/AILiveDriver.swift` installs the interceptor next
  to `liveObserver` in `runOnce` and records from `consume`.
- Coverage: `ModernTests/AICassetteCanonicalizerTests.swift` (keying) and
  `ModernTests/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 of `AIModelCatalog.swift` and the "ADDING A NEW
  MODEL" checklist in `AIMetadata.swift`.
- `sources/AITerm/AIModelCatalog.swift` - loads the catalog. A private `Codable`
  DTO decodes the JSON and maps it into the existing `AIMetadata.Model`. It
  loads both the bundled snapshot and any downloaded cache and uses whichever
  has the higher `version`. Entries whose `api`/`vendor` string 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; its `models`
  array and `recommended<Vendor>Model` / `alternate<Vendor>Models` accessors now
  read from `AIModelCatalog`.
- `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 is `ai-models.json` from 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`** (in `ai-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's
  `version` must 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 new
  `iTermAIAPI` serializer, a new `Model.Feature`, or a new `VectorStoreConfig`
  value that alters how a model is represented or selected). A purely additive,
  optional field older apps ignore when decoding (like `economyModel`) does not
  warrant a bump - a server catalog using it should keep `minimum_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 sets
`minimum_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** (its
`url` 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 that
`AIConversation.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:

1. Download the manifest from `aiModelCatalogURL`.
2. Filter entries to those whose `[minimum_ai_version, maximum_ai_version]` range
   admits this build and whose `version` is newer than what is already loaded,
   ordered highest-version-first.
3. Download the top candidate's payload.
4. Verify the payload's RSA signature (`iTermSignatureVerifier`,
   RSA-PKCS#1-v1.5 over SHA-256) against the bundled `rsa_pub.pem`.
5. Validate that it decodes to at least one usable model, satisfies the catalog
   invariants (see `AIModelCatalog.validate`: every `economyModel` resolves 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, so
`performPeriodicCheck` applies three gates in order:

1. **AI must be fully enabled**: allowed in advanced settings and in the secure
   setting (both covered by `iTermAITermGatekeeper.allowed`) and the plugin
   installed (`iTermAITermGatekeeper.pluginInstalled()`). If AI is not set up we
   never download and never even ask.
2. **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.
3. **One-time consent**: a tri-state
   `iTermUserDefaults.aiModelCatalogUpdateConsent` (`.unknown` / `.granted` /
   `.denied`, stored under a `NoSync` key). On first eligible check we present a
   modal; the answer is remembered so we never ask again. A dismissal leaves it
   `.unknown` so 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's
`signature` 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 `version` must equal the top-level `version` inside 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):

1. Edit `OtherResources/ai-models.json`. Capture any new refusal fixtures the
   tests require (see the "ADDING A NEW MODEL" checklist in `AIMetadata.swift`).
2. Bump the top-level `version`. Mandatory: the app only installs a higher
   version, and it must match the manifest entry.
3. 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).
4. Run the publish script:
   ```
   RSA_PRIV=/path/to/rsa_priv.pem tools/copy_models.sh
   ```
   It writes `~/iterm2-website/downloads/ai/models.json` and merges an entry into
   `manifest.json` (replacing this version's entry, keeping others), self-verifies
   the signature against `rsa_pub.pem`, `scp`s both files to
   `bryan:iterm2.com/downloads/ai/`, and opens the Cloudflare cache page so you
   can purge the two URLs. Set `AI_MIN_VERSION=<int>` to raise the entry's
   `minimum_ai_version` when the catalog needs a newer app.
5. 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 second
`l`) 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`. The `He` that 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 the `He` that 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):

1. 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?
2. 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)?
3. 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).** In `sources/MetalRenderer/Glue/iTermMetalPerFrameState.m` the
  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 logical `run->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 in
  `PTYMouseHandler.m`, `findOnPageSelectRange:` in `PTYTextView.m` (stores a
  visual range via `visualRangeForLogical:` into a logical selection),
  context-menu copy of a smart-selection action in
  `iTermTextViewContextMenuHelper.m` (passes `action.visualRange` to the now
  logical extractor), Look Up / force-touch (`showDefinitionForWordAt:` in
  `PTYTextView+ARC.m`), and right-click / pointer-gesture smart selection in
  `iTermURLActionHelper.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.** `firstStrongIsRTL` in
  `sources/Drawing/BidiDisplayInfo.swift` walks 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-off
  `detectParagraphDirection` advanced setting, so low impact, but a regression
  vs the `rangeOfCharacter(from:)` code it replaced.
- **Pre-existing typo (not introduced by this PR).**
  `iTermAttributedStringBuilder.m` has `bidiLUT[i - i]` (always index 0) where
  `bidiLUT[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 (the `stripZeroWidthFormatCharactersOnPaste` advanced
  setting description), which the em-dash rule prohibits.

## Next steps

1. Wait for the author's analysis on the mouse selection model.
2. Fix the Metal selected fg/bg coordinate mismatch regardless of direction (it
   is a bug under any model).
3. 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.
4. If we land on **logical** for the mouse: finish the conversions at every
   selection entry point so the model is consistent.
5. 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 to `cat` in 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.0
```

Every 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.
  `iTerm2ForApplescriptTesting` at 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))
EOF
```

## 3. 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 url
```

The vendored ones with a macOS Xcode target: **CoreParse, Highlightr,
MultiCursor, NMSSH, Sparkle, SwiftyMarkdown**. For each:

1. Bump **every** `MACOSX_DEPLOYMENT_TARGET` in its `.xcodeproj` that 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 prebuilt `NMSSH-OSX/Libraries/lib/*.a`
   (openssl/libssh2) that get rebuilt at the new floor by the deps build.
2. Commit inside the submodule: `git -C submodules/X commit -m "Bump
   deployment target to macOS 13"`. These land on a detached HEAD.
3. Bump the superproject pointer: `git add submodules/X`.
4. **Push the fork** so other machines/CI can fetch the new SHA:
   `git -C submodules/X push origin HEAD:<branch>`.

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 `.vN` enum must exist in that package's `swift-tools-version`
  (`.v13` needs 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 read `Package.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-deps
```

`paranoid-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.plist
```

## 7. 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-pwmadapters
```

`BUILD_DIR=...` is required: the sandboxed sub-make cannot run
`xcodebuild -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}')"
done
```

## 8. 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 -u
```

Then 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+, the
`pythonRuntimeUsesUV` advanced-setting default in
`sources/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's
`roomSecret`: 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 ---> phone
```

The 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.

1. 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 the `relay=` 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.)
2. The phone generates its per-pairing key and attests, binding the
   attestation to
   `clientDataHash = SHA256(canonical("iterm2-relay-attest", [challenge, relayOrigin]))`,
   where `relayOrigin` is 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
   that `relayOrigin` is its own. That last check closes a cross-relay
   forwarding attack: a hostile QR pointing at `relay=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.
3. 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.
4. 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 whenever
`relay=` 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 is
`HKDF-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 against `V`. (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 only
`V` 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:

1. The phone mints the 32-byte `roomSecret` (kept in the Keychain,
   rotated on unpair) and derives the signing keypair.
2. The phone sends `roomSecret` to 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.
3. Only after that ack does the phone register `V` with 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 holds `roomSecret`. If the phone registered `V` first
   and the session died before `roomSecret` reached 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 alongside `V`, and the DO accepts a room's first
   `V` only with a valid, unused token. That bounds the open-mode race
   over who gets to register `V` to 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.
4. The relay accepts only the first registration of `V` for 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 own `V` and take over
   the room. As a result, `V` cannot 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`, computes
`roomName = SHA256(canonical("iterm2-room", [rs, pid]))`, and opens a
`wss` 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 bytes
```

On 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 6
```

With 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 encrypted
```

The 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 derive
`SAS = 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 established
```

The 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 from
`roomSecret` 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 generic
`Challenge { 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 against `V`.
- *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), and `roomSecret`. UserDefaults (non-secret): the
  paired `pid`, the Mac's Noise static public key `rs` (from the QR),
  and the relay URL. The join signing keypair is derived from
  `roomSecret` on demand, not stored.
- **Mac.** Keychain: the Mac's Noise static private key and
  `roomSecret`. User defaults: the paired `pid`, 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 from `roomSecret`.
- **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 raw
  `roomSecret`, or the `pid`.

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 (only `V` is 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.

1. **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."
2. **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.
3. **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_REQUIRED` fails closed. Anything other than the exact
     string `false` means attestation is required, so one botched
     config edit cannot silently open the relay. Only an explicit
     `false` disables 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: `generateKey` can
     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 `/attest` call),
     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.
4. **Bring your own relay.** Anyone can `wrangler deploy` the same
   Worker to their own free account (hard-capped, $0) and point the
   Mac's relay setting at it; the QR's `relay=` 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 with `ATTEST_REQUIRED=false` is 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 of `root_` in that container (section 3.5).
- Each float is a wrapper view that holds the outline, shadow and resize
  handles, with a one-child `PTYSplitView` inside it. The split view keeps the
  many `(NSSplitView *)view.superview` casts 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.superview` to `NSSplitView` and 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:`, the `splitView:` delegate methods,
  `_recursiveEncodeArrangementForView:`, `_recursiveRestoreSplitters:`).
- The `root_` identity tests that must become "is a root" are few:
  `assert(parentSplit == root_)` in `splitVertically:newSession:before:targetSession:`
  (PTYTab.m:2499), three cases in `cleanupAfterRemove:` (2091, 2129, 2137),
  `_recursiveViewFrame:` (2162, which walks up superviews until it meets
  `root_` 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 in
`hiddenLiveViews_` with no superview.

Known hazards for the floats in the container:

- `setRoot:` (PTYTab.m:5212) replaces `flexibleView_`'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) and
  `setActiveSessionPreservingMaximization:` replace `root_.subviews[0]` with
  the target session's view while maximized. With a float as the target they
  would move the float into `root_`.
- 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 new
`tiledSessions` 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 > 1` in 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) and
  `sessionsAtTop/Left/Right/Bottom` (1400). `minSize` measures 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 in `sessions` after rebuilding the tiled tree.
- Menu validation: `hasMultipleSessions` (PTYTab.m:3048) counts `root_`
  subviews while other items use `sessions.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 is
`currentTab.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:` sets
`tabView_ = 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 of `flexibleView_`'s subviews.
- `enableFlexibleView` (3562-3574) and `notifyWindowChanged` (3577-3582),
  which create the container lazily and for tmux only.
- `setTabViewItem:` (1311) and `replaceWithContentsOfTab:` (3515), which moves
  `root_` 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 resizes `root_` directly and the
  tmux branch resizes `tabView_`. Native must size the container and have the
  root follow.
- The autoresizing mask set at 5224 is max-margin only. That works today
  because `NSTabView` sizes 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 skips `root_` when moving
  delegates.
- `_ancestorsOfLockedSession` (6432-6440) and
  `textViewSplitPaneWidthIsLocked:` (PTYSession.m:22055-22057). Both stop on a
  nil superview, so they are safe, but from a float they never reach `root_`.

**Coordinates relative to `root_`**

- `rootRelativeOriginOfSession:` (1080), the `sessionsAtTop/Left/Right/Bottom`
  comparisons (1405-1439) and directional navigation (1492-1588). These still
  convert correctly for a float.
- `sessionContainerView:` (7630) returns `root_` 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 if `realRootView` returns 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) read `session.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 pass
  `metalAllowed:`. 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).
- `SessionView` bounces 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 an
`NSVisualEffectView` 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` and
`textViewContainerRect`, PTYSession.m:13426-13450) to
`iTermBackgroundImageRenderer`, and the legacy path does the equivalent in
`iTermBackgroundDrawingHelper`. A float is another rectangle and draws its
slice the same way. What needs handling:

- `sessionContainerView:` must return the tab container rather than `root_`
  (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:

- `_showTitle` is per `SessionView` (SessionView.m:139) but the tab sets it
  from `sessions.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.
- `iTermActivePaneBorderView` stays 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 `compactFrame` for 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 of `fitSessionToCurrentViewSize:` (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. `fitTabsToWindow` runs for every tab, so
  the clamp must run for background tabs too.
- **Tab size.** Floats never contribute to `size` or `currentSize`. 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:` enforces
  `minSize` for 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 API `grid_size` setter, 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 to `setupSession: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 a `SessionView`. 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 the `idMap_` 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`, AppleScript `split`, the orchestrator's `SpawnWindowChoice`
- 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, calls `setActiveSessionPreservingMaximization:`, which
  replaces `root_.subviews[0]` with the target view. Reveal must unhide floats
  if hidden, raise the target, and never move a float into `root_`.
- 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-tab `activityCounter` (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. With
  `navigatePanesInReadingOrder` on 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 by `activityCounter`, and floats take
  part like any pane.
- While maximized, the reading-order branch builds the list from
  `_orderedSessionIDs`, which comes from `idMap_` (PTYTab.m:1089). Floats are
  not in `idMap_`, so with a float active `sessionInDirection:` 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, since `helpTextForIndicatorWithName:sessionID:` is computed per
  session. That string needs plural handling per `docs/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 by `iTermIndicatorsHelper`.
- 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 in `allSessions`.
  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's
  `iTermSessionPicker` uses 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 follow
`docs/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) and
  `splitVertically: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 is
  `canSplitPaneVertically:`, 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)` then
  `completion(nil, NO)`) or a suspended AppleScript command never resumes.
- `splitVertically:before:addingSession:targetSession:performSetup:` (11441)
  is called directly by `iTermSessionLauncher` (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's `start_session` with `window=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) and `sessionInitiatedResize:`
  (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 through `sessionInitiatedResize:`.
- 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 before `beginDrag:`. 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 tab` is positional in `sessions`
  (PTYTab+Scripting.m:26). Tiled sessions come first and floats after, so
  existing indexes keep their meaning.
- `session.termid` and `ITERM_SESSION_ID` take the pane number from
  `sessions.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 in `sessions`.
- Apply modes for actions and snippets (PTYSession.m:12094) use `sessions`,
  `allSessions` and `broadcastSessions`, 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 through `recreateTab:`, 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. `SessionView` registers 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-cases `tab.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 runs `setRoot:`.
- `requireOptionToDragSplitPaneTitleBar` applies 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 `SessionView` installs tracking areas over its bounds with
  `NSTrackingActiveAlways` (SessionView.m:1537-1562). They are not
  occlusion-aware (unverified AppKit behavior). The expected failures: a tiled
  pane under a float also gets `mouseEntered`, so focus-follows-mouse can steal
  focus; moving from a float back onto the pane it sits inside produces no
  `mouseEntered`; and `mouseMoved` reaches the covered pane, driving URL
  underlines, cursor changes and mouse-motion reporting. The fix is a
  window-level `hitTest` gate 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
  carry `Is Active` (PTYTab.m:3242), and `Node` contains one. A second flag
  would be a second source of truth.
- `Desired Grid Size` backs the shrink-and-remember rule in section 5.
- `Node` holds 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 `PTYTabArrangementOptionsOnlySessionID` is 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 `repairedArrangement` variants (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 only
`root_` 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.** `maximize` snapshots the tab and `unmaximize` rebuilds from the
  snapshot. Floats stay out of `idMap_` and `savedArrangement_` and are encoded
  live in both branches of the encoder (PTYTab.m:3969-3997).
- **Duplicate Tab.** `arrangementForDuplication` then `tabWithArrangement:`.
  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 named `value`,
  `text`, `name`, `title`, `command` or `url`.

## 11. API and scripting

- **Proto.** Add `repeated FloatingPane floating_panes = 12` on `Tab`, with a
  `SplitTreeNode` (or session summary), frame and z-index. Never add a `oneof`
  case to `SplitTreeNode`: the Python client's `Splitter.from_node` treats 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) is `sessions` minus the active session,
  so once `sessions` includes floats, every float in a maximized tab is
  reported as minimized while it is still visible. It must use
  `tiledSessions`.
- **Consistency.** Today the session list comes from `rootSplitTreeNode` while
  subscriptions, variable scopes, activate, close and split look sessions up
  through `allSessions`. 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, through `PTYSessionDidResizeNotification`
  (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. `SetTabLayout` checks tree shape only and is unaffected in
  native tabs.
- **Python client.** Add `Tab.floating_sessions`; include floats in
  `all_sessions` and `get_session_by_id`; keep `Tab.sessions` tiled-only.
  `Tab.current_session` (tab.py:189) searches `sessions` and returns `None`
  when the active session is a float, in any tab; it must search all
  sessions. `Tab.sessions` can be empty for a floats-only tmux tab.
- **`apply_layout`** must see tiled sessions only, or existing scripts fail
  with `orphanedSession` on any tab that has a float.
- **AppleScript.** `sessions of tab` is `[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_direction` excludes floats.
  `move_session_to_new_tab` and `move_session_to_new_window` work for a float
  through `MovePaneController`.

Further items from the audit of every endpoint:

- `active_session_id` and the Focus response name a float that is not in
  `root`.
- `SplitPane` with `session == "all"` iterates `allSessions`. One refused
  float sets `CannotSplit` for the whole response although the tiled splits
  succeeded (iTermAPIHelper.m:2675).
- `Activate` with `select_session` on a maximized tab does unmaximize, set
  active, maximize (iTermAPIHelper.m:3130). With a float target the last
  step would maximize the float.
- `GetBroadcastDomains` lists float IDs that a client cannot resolve from the
  tree.
- In the Python client, `Tab.all_sessions` (tab.py:165) is what
  `get_window_and_tab_for_session`, `Session.tab`, `Session.window` and the
  layout-change merge go through. Floats must be in it, or `Session.tab` is
  `None` for a float. `Tab.update_session` and `Tab.update_from` must 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).
- `it2` resolves a missing session argument to the first leaf of the
  selected tab's tree, not to `active_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
  add` rejects 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_layout` resolves 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/-y` and `new-pane` take outer coordinates when the float has
  a border. Sending layout values back unchanged drifts by one or two cells.
- Layout `w` includes reserved scrollbar columns. `pane_width` is the content
  width.
- `pane_z` is non-empty for tiled panes, so it cannot identify a float.
  `pane_floating_flag` cannot 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
  unzoomed `window_layout`, which still lists hidden floats.
- While a modal pane exists, tmux adds `O` to the window flags sent with
  `%layout-change` (window.c:1395, undocumented). `#{window_modal_pane}` gives
  the pane ID.
- After `break-pane -W` on 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, and
  `rotate-window`. A `refresh-client -B` subscription on `#{window_layout}`,
  which always uses the JSON format, is required to stay in sync.
- `display-popup` and the buffer and customize editors are modal floating
  panes with pane IDs and `%output`. A control client's own `display-popup` is
  a silent no-op, but one opened by another client is visible.
- While a pane is modal, `select-pane` to another pane silently does nothing,
  and swap, join and break of the modal pane fail with "pane is modal".
  `send-keys` to other panes is not blocked (unverified by running).
- `split-window` on a float creates another float. `swap-pane` exchanges
  cells, so a tiled pane can become floating.
- `break-pane -W` makes a tiled pane floating. `join-pane` with 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 `-A` or as a modal with `-O`, which carry `PANE_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 `-A` float can be the active pane over a zoomed pane.
- Creating an `-A` or `-O` float in a zoomed window restores the zoom
  afterward (`restore_zoom`, cmd-split-window.c:146). Other commands that
  need the full layout unzoom first through `window_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-layout` after opting in and
  leaves floats alone. Keep `iTermTmuxLayoutBuilder` on v1. A JSON layout would
  need quoting and would also apply active-pane and z-order state.
- Default bindings create floats: `*` runs `new-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 through `iTermTmuxOptionMonitor`.
- Test fixtures: `regress/layout-custom.sh` has 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 gets `0000,` for a window with only floats. `parsedLayoutFromString:`
  returns nil and TmuxWindowOpener.m (107, 159, 568) calls
  `abortWithErrorMessage:`. 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 the `list-panes` that 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 in `openWindows:` and
  `decorateParseTree:` 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)
  feeds `TmuxDroppedOutputScanner.didLearnPanes` from
  `windowPanesInParseTree:`. It must include float pane IDs, or a float's
  `%output` counts 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.
- **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 `SessionTitleView` is 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-lines` is 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.
- **Border status.** Floats reserve no row for `pane-border-status`.
  `adjustLeavesInNode:` (TmuxLayoutParser.m:148) would wrongly shrink a float
  at y = 0.
- **Client size.** `variableTmuxSize` filters `sessionViews` by geometry and
  would count floats as stacked tiled panes. Floats must not contribute.
- **Raise.** `%window-pane-changed` to a float must bring that float to the
  front, since the server raises a float when it becomes active.
- **Move and resize.** Send `move-pane` and `resize-pane` with the outer
  coordinate conversion. `_isDraggingSplitInTmuxTab` forces 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.** `maximize` assumes the zoomed pane is the active session. With a
  modal or `-A` float 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
  unzoomed `window_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 to
  `break-pane`, which makes the pane tiled in a new window.
- **Dock.** “Dock Floating Pane” maps to `join-pane` on 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 run `setRoot:` (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 mode `fitLayoutToWindows`
  (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.
  `variableTmuxSize` needs the same treatment.
- **Tab size.** `size` and `currentSize` come from `root_`, 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 the
  `setTmuxLayout:` paths must handle a tree becoming empty (last tiled pane
  killed) and becoming non-empty again (a float is tiled). Maximize and
  `root_.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 is `0000,` (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 because
  `replaceViewHierarchyWithParseTree:` already empties the tree transiently.
  The ones to fix are `replaceWithContentsOfTab:` (3544, 3548) and the split
  assert at 2492.
- Callers moved to `tiledSessions` during 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's `tmuxSize`. One floats-only tab reporting 0 makes the whole
  window's size 0. It feeds `setClientSize:` (PseudoTerminal.m:5266,
  TmuxController.m:1036).
- `tmuxSize` is 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 reads `currentSize` and `minSize`
  (PseudoTerminal.m:11803, 11812), as do `minWidth` (12237) and
  `windowWillResize:toSize:` (5953). Those take a maximum, so a zero is
  harmless there, but they must see the float contribution to `minSize`.

Tab construction and layout:

- `openTabWithArrangement:` (PseudoTerminal.m:12259) returns nil when
  `sessionViews` is empty. It stays correct only if `sessionViews` includes
  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.
- `TmuxWindowOpener` builds its per-pane requests with
  `depthFirstSearchParseTree:` (TmuxWindowOpener.m:112, 663) and its pane
  list with `windowPanesInParseTree:` (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 empty `Splitter`, so
  `Tab.sessions` is an empty list and scripts that index it raise.
- `apply_layout` already refuses tmux tabs (iTermLayoutResolver.swift:124),
  so the empty tree never reaches it.
- On a floats-only tab an empty `SetTabLayout` request 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) read `sessions`, which includes floats.
- cmd-opt-N (iTermApplication.m:465), the Cockpit and the AI chat mention
  picker read `orderedSessions`, which includes floats.
- `arrangeSplitPanesEvenly` and 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 true` opens 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, the
  `tab.currentSession` variable and focus history, and closes Instant Replay on
  the previous pane. A popup becoming active makes the tab title flicker unless
  `set-titles` is 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
  `O` window flag in `%layout-change`, and which pane from
  `#{window_modal_pane}` (section 12.1). `parseLayoutChangeCommand:`
  (TmuxGateway.m) reads only `Z` from 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 before `select-window`;
  if tmux refuses during a modal, the next real notification may be swallowed
  (unverified).
- **Dismissal.** A popup without `-E` leaves a dead pane waiting for a key.
  That dismissal is in tmux's client-key path, which `send-keys` bypasses.
  iTerm2 gets no notification that a pane died. Close with `kill-pane` or
  `display-popup -C`.
- **Process-less panes.** `new-pane -E` panes never send `%output`, and modes
  draw to a mode screen that `capture-pane` reads only with `-M`. They would
  render blank while keys still drive the mode. iTerm2 ignores
  `%pane-mode-changed` today, 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.** `bury` on 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 `%output` for 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.

0. **tmux fixes** from section 12.2. Independent; ship first.
1. **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).
2. **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:` and `cleanupAfterRemove:` changes,
   `sessions` versus `tiledSessions` with the temporary rename, and the
   call-site classification. While classifying, mark each `tiledSessions`
   caller that cannot handle an empty list; those are fixed in step 10. Add
   tests for remove, maximize, enumeration and
   `replaceViewHierarchyWithParseTree:`; none exist today.
3. **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.
4. **Focus and mouse.** Occlusion-aware hit testing for focus-follows-mouse and
   hover, navigation order, numbering, maximize.
5. **Persistence.** Encoder, decoder, walkers, trust gate, partial attachment,
   undo close and bury, Duplicate Tab, with round-trip tests.
6. **Dragging.** Drag-out to tab bar, window and split with Control; then the
   floating drop target and spring-loaded tabs.
7. **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.
8. **Automation.** Proto field, handlers, Python client, `it2`, AppleScript,
   built-in functions, and the consistency items in section 6.9.
9. **Remaining creation points** from section 6.1.
10. **tmux.** In this order:
    1. The JSON parser, with floats reaching every parse-tree consumer
       (section 12.5 audit).
    2. Opt-in and the server-owned geometry driver, with local cell
       alignment.
    3. Floats under zoom.
    4. Floats-only windows: tab and client size for an empty tree, then
       transitions, then the
       `tiledSessions` callers marked in step 2. The size work comes first
       because a zero from one tab reaches every window in the session.

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 `-suite` instance. 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
  `-suite` instance 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 modal `trackEventsMatchingMask:` 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`, `size` and
  `tmuxCompatibleSize` for a window that mixes a floats-only tab with a
  normal one; the tree going empty and non-empty through
  `replaceViewHierarchyWithParseTree:`. PTYTab container operations (step
  2); arrangement round trips including old-format input; the trust gate with float sessions; the
  JSON layout parser against fixtures from `regress/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/tmux` and run `tmux -CC` with a
  second plain client: `new-pane` variants, `display-popup`, `break-pane -W`
  and `join-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 the
`InfoPlist.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 that
`shouldTranslate: 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:

1. The **base plist supplies the keys and English values.** These keys are
   functionally required regardless of localization (the app cannot request
   camera access without `NSCameraUsageDescription`), so they already existed.
2. `InfoPlist.xcstrings` holds the **extracted English source plus translations**,
   keyed by the same plist keys.
3. At **build** time the catalog compiles to `<lang>.lproj/InfoPlist.strings` in
   the app bundle (one file per language).
4. At **runtime** macOS overlays those `InfoPlist.strings` values 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 `source` would 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.com` resolves 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:

  ```
  iterm2://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 pins `rs` in 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 the `relay` URL**. 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:

1. **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 `bufferutil` is installed.
2. **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` (and `utf-8-validate`)** so `ws` unmasks 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_QUOTA` today). 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=<url>`, `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 points `relay=` at it. **Retained unchanged** — no shard map, no resolver.
- **Resolved mode (`resolver=<url>`, `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 emit `v=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 knob
```

The 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 of `roomName`. A power of two makes the bit-slice exactly `roomName mod 65536` with 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-char `x-relay-room` header. A mismatch here silently breaks rendezvous, so ship a **test vector** (a fixed `roomName` mapping 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:

```json
{
  "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:

1. Compute `roomName` via the `RelayRoom` derivation (§6.2) from the QR fields, then `bucket = uint16be(roomName[30], roomName[31])`.
2. **Connect immediately to the last-known host** for this pairing (a cached `bucket → host` *hint* — epoch-free; the client does not reason about ownership).
3. **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.**
4. **Ignore any map older than the newest version already seen** (monotonicity — see §6.6).
5. **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-owner` header 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 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-owner` header 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 `/delete` all 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 `/attest` rate 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. `/delete` stays 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`), and `roomSecret` is 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, not `roomName` alone), 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):

1. The phone reconnects and attempts a signed (established) join.
2. The host, having no verifier, is in pairing mode and answers `"ticket required"` (or the explicit `"unestablished"` signal of §6.5).
3. 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 lost `registrantKeyId` too, so nothing needs to match, and the new key's assertion counter starts fresh.
4. 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.json` every `SHARDMAP_POLL_INTERVAL` (default **10 seconds**; a few seconds to a minute, operator's choice; Appendix C); when the fetched map's `version` exceeds 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_INTERVAL` is 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 `host` entries 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`), not `roomName` alone.
- 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:

1. Its **TLS certificate**: the cert SAN is that hostname (the on-box proxy of §5.1 obtains a certificate for exactly that name).
2. Its **map identity**: the box owns the buckets of every shard-map range whose `host` field equals this hostname. This is the answer to "which line of the map is mine" (§6.5).
3. 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_old` has evicted its copy of the room (§7.4), one endpoint can be on `H_old` and the other on `H_new` until `H_old` evicts 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 **2x `SHARDMAP_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_DELAY` plus 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 `reshard` and 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/K` from 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 H1
```

Each 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`/`65535` seam; the affected host then holds a wrap arc, written as two ranges. Since buckets are uniform, *which* point is called `0` is 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 `rs` is the Mac's static public key, and the `Noise_XK_25519_ChaChaPoly_BLAKE2s` handshake 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-owner` hint 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 map `host` in resolved mode, the QR `relay=` 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 `roomName` and 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

1. **Ship clients that understand both pairing modes:** direct (`relay=`, `v=1`) and resolved (`resolver=`, `v=2`). Mode is selected by which parameter is present.
2. **Existing beta users keep working** — their `v=1` `relay=` QRs are unchanged and still connect directly. No re-pairing required.
3. **Self-hosting stays on direct mode** — a user running one relay emits `relay=<their box>`; nothing else needed. (A self-hoster wanting sharding can run their own resolver and emit `v=2`.)
4. **Introduce the shard map** as a static CDN file plus a **resolver URL**; the managed service emits `v=2` `resolver=` QRs for new pairings. Bootstrap the map with today's single box as the sole entry (`ranges: [{0..N_BUCKETS-1 → current host}]`).
5. **Add `bufferutil`, disable `permessage-deflate`, add backpressure handling** in the relay (independent of mode; immediate throughput win).
6. **Stand up a second box**, publish a two-range map, exercise a reshard end-to-end on beta traffic.
7. **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.
8. Grow by adding boxes and republishing the map as real adoption numbers land.

---

## 11. Open questions / future work

- **Geo-aware placement.** Sharding on `roomName` is 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 registered `V'` to satisfy `roomName' == 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)

1. **`N_BUCKETS` is immutable** (fixed at 65536 = low 16 bits of `roomName`). Changing it rehashes every pairing. The bit-extraction convention must be byte-for-byte identical across Swift and Node — ship a test vector.
2. **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.
3. **Everyone ignores map versions older than the newest they've seen.** (Monotonicity.)
4. **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.
5. **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.
6. **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/K` of buckets (§7.5). Only a planned, higher-churn rebalance may move boundaries wholesale to even out drift.

7. **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 `host` entries (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://" + host` from the map verbatim (resolved mode) or from the QR `relay=` value (direct mode), so the map `host` must 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 to `sources/Localizable.xcstrings`, which is gitignored; never edit that copy.
- **XIB strings** - one `mul.lproj/<name>.xcstrings` per 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 (a `NoSync…` user-defaults key, a Codable
  `rawValue`, 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`/`RLog` and 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 `errorReason` returned through the Python
  scripting API (`ITMInvokeFunctionResponse.error.errorReason`, surfaced as
  `RPCException` messages) *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:

```swift
// 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 `%ld` to `%@`.
- `String(localized:)` and `+[NSString localizedStringWithFormat:]` are
  locale-aware and apply the locale's digit grouping to integer specifiers
  ("10,001"), so you rarely need a `NumberFormatter`.
- Extraction quirk: `extractLocStrings` normalizes 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.sh` has 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:`, **not** `stringWithFormat:`, or the
  plural rule is never applied.

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` and
`other` `stringUnit`s (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.xcstrings
```

The key's compiled `en.lproj/Localizable.stringsdict` entry should have
`NSStringFormatSpecTypeKey = NSStringPluralRuleType`.

Preservation is conditional - any of these **silently un-pluralizes** the site:

1. 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.
2. Dropping the key's code reference - it goes stale and is eventually pruned.
3. Changing the English `defaultValue` without hand-updating the variations -
   sync can't tell which plural category the flat source maps to, so keep the
   `other` value identical to the `defaultValue`.

### 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 a
`substitutions` 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`) and
`NonTextPaste.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 into
`xcstrings/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, opening
`Localizable.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: run
`make 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` and
`substitutions` 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.profileName` variable
- 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 name
- `iTermTitleComponentsJob` - Current foreground job
- `iTermTitleComponentsWorkingDirectory` - Current directory
- `iTermTitleComponentsTTY` - TTY device
- `iTermTitleComponentsProfileName` - Profile name
- `iTermTitleComponentsProfileAndSessionName` - Both
- `iTermTitleComponentsUser` - Username
- `iTermTitleComponentsHost` - Hostname
- `iTermTitleComponentsCommandLine` - Full command line
- `iTermTitleComponentsSize` - Terminal dimensions
- `iTermTitleComponentsCustom` - Custom string (mutually exclusive)

Custom title functions registered via the iTerm2 Python API also appear in this dropdown.

### Title Providers

Sessions can use:

1. **Built-in title provider** (default): Uses `iterm2.private.session_title()` function
2. **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

1. `titleOverride` (if set) - a swifty string
2. Active session's `presentationSessionTitle`
3. For tmux: `tmuxWindowName` is 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:
1. OSC 0/2 window title (if set by escape sequence)
2. Custom title provider result
3. Built-in title computation
4. 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):

```objc
- pushWindowTitle  // Save current title
- popWindowTitle   // Restore saved title
- pushIconTitle
- popIconTitle
```

These 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 title
```

Values 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 only
```

### OSC 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 > tmuxPaneTitle
```

The `%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.titleOverride
```

### Data Flow

1. Profile created with `KEY_NAME`
2. Session inherits profile, sets `session.profileName`
3. `autoNameFormat` defaults to profile name
4. Escape sequences can set `windowName`/`iconName`
5. `iTermSessionNameController` evaluates title format
6. Computes `presentationSessionTitle` and `presentationWindowTitle`
7. `PTYTab.title` uses override or session's presentation title
8. `PseudoTerminal` updates 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 via `it2 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 named `bw` and the `bw` symlink
  target (e.g. `bw.js`), which fixes selecting
  Bitwarden’s `bw` on 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
  new `it2 session archive-clippings`
  command and matching `iterm2.archive_
  clippings` API 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
  12811.
- 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
  12857.
- 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
  12852.
- 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 <url>", 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
  12763.

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
  13092.
- 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-status` prints a session’s
  status indicator as JSON. `set-status` no 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
  13035.
- 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
  13092.
- 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 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, Workgroup toolbar, or
  a web browser’s navigation buttons. A small
  arrow appears in the corner instead; click it to
  show or hide the window buttons. Issue 13100.
- 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.
- 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.
- The Script Console now keeps only the 200 most
  recent finished scripts and API connections.
  Tools that connect for each command, like it2,
  used to pile up enough of them to slow iTerm2
  down over time.
- The Bitwarden, KeePassXC, and Keeper password
  manager integrations now find command-line tools
  installed by Homebrew or npm by using your
  shell’s search path. The Bitwarden integration
  now reports the Bitwarden CLI’s own error
  message, and says so when your vault has no
  folder named “iTerm2”.
- The AI Usage toolbar item now finds claude using
  your shell’s search path and respects
  CLAUDE_CONFIG_DIR set in your shell startup
  files.
- AI chat asks for approval less often when it
  types a message you dictated into another
  session.
- When an AI chat’s Stop button or Disable
  Orchestration cancels pending timers or watches,
  the chat now says so.
- Opening an Instant Replay recording now only
  applies appearance settings like colors, fonts,
  and cursor style from it. Settings that could
  run commands, such as triggers, are ignored.
  Issue 13128.
- Installing or reinstalling the Claude Code
  Integration no longer replaces a settings.json
  that has a syntax error, keeps a symlinked
  settings.json as a symlink, and keeps hook
  commands you edited to use $HOME, ~, or a
  wrapper script. Issue 13098.
- Debug logs no longer include passwords, password
  field contents, AI API keys, or custom AI HTTP
  header values.
- iTerm2 now warns you if another copy of it is
  already running with the same settings, and
  offers to switch to it.
- Restorable session state is again saved with
  SQLite’s write-ahead logging, reducing disk
  syncing, and the restorable-state.sqlite-wal
  file no longer grows as large as the saved state
  itself.
- Installing shell integration without internet
  access now works on systems without bash, such
  as FreeBSD and OpenBSD, and no longer gets stuck
  at its first step for zsh users.

Bug Fixes:
- Fixed various crashes, including ones involving
  the GPU renderer (when switching to or from
  battery power, after changing the font size,
  after closing a session, or after running for
  several weeks), Smart Selection rules or
  triggers with no regular expression, Dynamic
  Profiles with values of an unexpected type,
  quitting while still starting up, fonts that
  could not be measured, accessibility apps like
  VoiceOver, splitting a pane that moved, tab
  groups, tmux integration windows left open after
  detaching, exiting full screen in a window
  without a title bar, Find, “Import All Settings
  and Data”, Set Profile Setting triggers, profile
  names starting with a combining mark, and
  Cmd-hovering over a composer autosuggestion.
  Issue 13103.
- Fixed a crash that could recur every time iTerm2
  saved window state after quitting while a save
  was in progress.
- Fixed saving a window arrangement with “Save to
  File with Contents…” and archiving a session
  when the text contained hyperlinks or similar
  styling. Issue 13143.
- 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.
- Quitting iTerm2 no longer unlinks each AI chat
  from its terminal session or cancels what the
  chat was watching when window restoration brings
  the session back.
- A program that sets the window or tab title over
  and over no longer makes iTerm2 fall far behind,
  use full CPU for minutes, and grow to gigabytes
  of memory.
- When an SSH Integration connection dropped, the
  session hung. It now ends the connection and
  returns to the local shell. Long SSH Integration
  requests also no longer occasionally fail.
- In a session that ssh’ed to another machine
  without SSH Integration, the current directory
  no longer switches to the local Mac’s directory
  after you run a command.
- Fixed a rare race where a new session could lose
  some of the first output it received.
- Fixed restored sessions bringing lines that had
  been erased by a clear screen back onto the
  screen.
- In “Require Authentication on Every Open” mode,
  the password manager no longer shows your
  accounts without asking you to authenticate when
  reopened. In “Once per Launch and after Screen
  Locks” mode, locking the screen now always
  requires authenticating again.
- An AI chat set to Llama (Local) no longer sends
  its messages to your cloud AI provider when no
  Ollama model is available.
- Stopping an AI chat reply no longer cancels
  another chat’s reply, really stops downloading,
  and no longer shows the rest of the stopped
  reply as the answer to your next message.
- Editing or deleting an AI chat message now fully
  removes the later conversation.
- Fixed a memory leak where each AI request left
  memory in use until iTerm2 quit.
- AI command suggestions that use a file name now
  insert the whole file name instead of only the
  part you hadn’t typed yet.
- Fixed “Export All Settings and Data” failing
  with “Failed to copy file” after using SSH
  Integration.
- Archive Sessions on Closure failed with “the
  volume is read only” when no archive folder was
  chosen. It now uses ~/Library/Application
  Support/iTerm2/Archives. Issue 12980.
- Fixed archiving of split panes with the same
  name, and of sessions closed shortly before
  quitting.
- Pasting or dropping a file into a local tab no
  longer offers only to upload it when another
  tool reports an outdated host name. Issue 13117.
- Semantic History can once again open files in
  Xcode when the active developer directory is the
  Command Line Tools. Issue 13091.
- Semantic History could open the wrong file in
  TextMate, BBEdit, or MacVim when the path
  contained special characters.
- With “Report keys using CSI u” on and Option set
  to Meta, Option with a letter now sends the meta
  byte.
- Old key bindings no longer hijack the A key when
  “Interpret key bindings based on physical key”
  is on. Issue 13110.
- The “Animate large movements” cursor option no
  longer streaks a trail across the screen when a
  program redraws on its own. Issue 13034.
- Find now matches text that spans a blank the
  program created by moving the cursor, as in
  output from Claude Code.
- Copying part of a line that mixes left-to-right
  and right-to-left text no longer drops the space
  between them.
- With the GPU renderer off, a right-to-left word
  whose letters had different colors no longer
  draws in a single color.
- The Prevent Sleep checkbox in Settings >
  Profiles > Session now takes effect, and Prevent
  Sleep is honored again after Restart Session and
  Undo Close.
- In tmux integration, a new pane now picks up
  user variables, host name, and directory that
  its shell reported while opening. Issue 13006.
- In tmux integration, a window created with “tmux
  new-window -d” now opens as a background tab.
  Issue 13112.
- In tmux integration, iTerm2 could miss a window
  switch made by another client and keep showing
  the old tab.
- Answering “Always” to “Close tmux windows after
  detaching?” now closes only the tmux windows
  left open by the detach instead of every
  session.
- Fixed Find > Filter interacting badly with tmux
  layout changes.
- Opening a file or link in a split pane while a
  tmux integration tab is current now opens it in
  a new tab.
- On macOS 26, hiding a hotkey window when no
  other iTerm2 windows are open once again returns
  focus to the previous app right away.
- Centered hotkey windows no longer return to
  their original size each time they are shown.
  Issue 13125.
- Move Tab Left/Right no longer pins or unpins a
  tab by moving it into a tab group.
- Voice Control corrections now replace text that
  ends at the cursor instead of appending.
- Cancelling an upload over SSH Integration no
  longer leaves a temporary file behind or gets
  stuck.
- In an alert with a text field, Cmd-A, Cmd-C,
  Cmd-V, Cmd-X, and Cmd-Z now edit the text.
- The SetWindowFrame escape sequence no longer
  shrinks a window to nothing or moves it off
  screen when given bad values.
- With KeePassXC as the browser password manager,
  “Remember this password” for a sign-in dialog
  now works.
- Fixed Tab.async_update_layout in the Python API
  making a browser pane far too big.
- Hiding a session note while typing in it no
  longer leaves keystrokes going nowhere.
- Fixed several Cockpit bugs: Opt-Return and
  Opt-Shift-Return sent nothing, a selection sent
  the whole field, the caret jumped while sessions
  changed status, sending after closing a chosen
  session sent nothing, and the panel could cover
  alerts.
- With multiple @-mentions and spaces in the
  Cockpit composer, the session picker now closes
  when it should.
- When settings load from a custom folder,
  quitting no longer asks to copy settings every
  time.
- 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.
- Fixed Notify on Status Change reporting a change
  from “none” or alerting when a program restated
  its status.
- The crash reporter’s message fills in your name
  again.
- Help popovers no longer cut off their last line.
- Choosing Show Session Note from Open Quickly no
  longer makes the note disappear.
- Clicking the icon of the Current Directory,
  Hostname, or Username status bar component opens
  its menu again.
- The API key field shown when AI features ask for
  a key now hides what you type.
- Closing the Companion setup wizard while it is
  installing now cancels setup.

---

### Notes 3.7 (`docs/notes-3.7.txt`)

Version TBD (unreleased; built off master)

Credit to Nimanou (nuved) for the
right-to-left rendering, selection, and copy
improvements in this release.

Major New Features:
- 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 and Codex now report 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.
- iTerm2 is now localized: the interface follows
  your Mac’s language and is available in
  Brazilian Portuguese, German, Japanese, Russian,
  and Simplified Chinese.
- 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 and can be controlled from
  the Python API. tmux integration shows tmux’s
  own floating panes (tmux 3.7 and later; moving,
  resizing, raising and docking them needs tmux
  3.8).

New Features:
- `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 own status hook
  is not yet installed on remote hosts, so its
  status is only reported automatically for
  sessions on this Mac.)
  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.
- New Python API functions set_session_status and
  get_session_status read and write a session’s
  status indicator by session ID, and
  session_id_for_tmux_pane finds the session
  showing a tmux pane.
- The Python API can now read, show, hide, and
  update a session’s Session Note.
- 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. Codex allows
  those two events one second by default, which
  is not always enough for the hook to finish.
  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 after the turn
  that started it. The tab gets a progress
  indicator too, which Codex does not draw for
  itself.
- 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).
- 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.
- The Ollama native API type now 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-capable local model thinks. Turning
  thinking off makes quick questions fast again.
  There is a new one-click “Ollama (native)”
  model preset, and the API type is now named
  “Ollama.” The manual model editor has a Fetch
  Models button that lists the models installed
  on your Ollama server, and a Vision checkbox so
  image-capable local models can accept images.

- New “Ollama (auto-discover)” model preset: point
  it at your Ollama server and it lists every
  installed model in the picker automatically,
  reading each model’s context window and
  capabilities (thinking, tools, vision) from the
  server. No need to name models or set
  capabilities by hand, and newly pulled models
  show up on refresh.

- Ollama is now a first-class provider in the AI
  model dropdown, alongside OpenAI, Anthropic,
  Gemini, and DeepSeek. Selecting it lists the
  models installed on your local server
  automatically, with no manual configuration. A
  non-local Ollama server is still added as a
  manual model. When Ollama is your default, pick
  which discovered model to use for regular chats
  and an optional smaller one for quick background
  tasks.
- 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.
- 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.
- The Python API can now discover and change
  tab groups. A Tab has a tab_group property; a
  Window has tab_groups and can create one with
  create_tab_group; and a TabGroup can be
  renamed, recolored, collapsed, or have tabs
  added or removed. iTerm2 keeps each group's
  tabs consecutive for you.
- The Python API can now run JavaScript in a
  browser session’s page and get the result
  back with Session.async_eval_javascript, so a
  script can read a page or fill in a form
  without clicking on the screen. The first time
  a script does this on a site, iTerm2 asks you
  to allow it. You can review and change these
  permissions, and those for loading URLs, in
  Settings > General > Magic.
- 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
  trigger action to keep the Mac awake only while
  a long-running job like an AI coding agent runs.
  By default it applies on AC power only; an
  advanced setting extends it to battery.
- 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.
- 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.
- The Session Status toolbelt tool has a
  snooze button next to the notify button
  that toggles snooze on the selected
  session.
- 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.
- ⌘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.
- New per-profile “Use HDR cursor when
  available” setting (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. Only sessions
  that enable it pay the extra fp16 cost.
- 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 commands from a
  full composer docked along its bottom (the
  same multi-cursor editor, in your profile’s
  font): opening the Cockpit lands the caret
  there. Type @ to pick a session, tab, or
  window from the same picker the AI chat uses
  (with a live preview); it becomes a chip and
  the rest is the command, sent verbatim. Return
  inserts a newline and Shift-Return sends.
  Mentions and the outline selection stay in
  sync: selecting a session, tab, or window row
  adds the matching chip (a tab or window chip
  fans out to all its panes), and editing chips
  reselects the rows. A status line under the
  composer confirms the count. The list and
  composer are resizable split panes and the
  list is a rounded inset card.
- The it2 command-line tool can now send a push
  notification to your paired companion device
  with “it2 notify «title» «body»”.
- 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. The advanced
  setting “Show the window name beside the
  tabs” can turn it off or always show it.
- 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.
- 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 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 top, bottom, left,
  and right 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.
- A session status can now be told 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. It 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 Python API’s
  set_status.
- 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.
- 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.
- 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).
- You can hide a browser session’s toolbar so the
  page fills the pane with Web > Show Toolbar, or
  from the Python API with
  Session.async_set_browser_toolbar_hidden.
- nushell is now a supported shell for shell
  integration, including automatic loading and
  SSH integration.
- 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.

Improvements:
- 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%.
- Python API context menu items can be grouped
  into submenus and shown only in sessions where
  an expression is true, such as a user variable
  your script sets. See ContextMenuProviderRPC’s
  submenu and visible_when arguments.
- 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.
- 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.
- 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 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.
- 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.
- 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.
- Reduced idle CPU usage that previously grew
  with the number of open windows.
- AI command safety checks now prefer the cheaper
  "economy" model when one is available, so the
  frequent low-stakes safety side-query costs
  less. It falls back to your configured model
  when no economy model exists.
- 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 / 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.
- 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, shows which key
  it picked, and sends a provider’s key only to
  that provider’s own servers: 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.
  This also helps with gateways like Open WebUI
  whose model names mention another provider
  (such as “gemini/…”). Issue 13105.
- 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.
- 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 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 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 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 tab group rename and creation dialogs now
  match the light/dark appearance of their
  window in the minimal theme.
- 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 Cockpit shows each session's live status in
  every grouping: the session's own status word
  (whatever it reports, in its own color) and the
  detail line now appear in Window and Workgroup
  views too, not just Status. A filter bar along
  the top narrows the list to any one status,
  with live counts; the status list is dynamic,
  so arbitrary statuses from any tool just work.
  Window/tab/session rows also carry the matching
  macwindow / folder / terminal icon.
- Duplicate Tab now opens the copy immediately to
  the right of the original tab instead of at the
  end of the tab bar. There is a new advanced
  setting, “When you duplicate a tab, should the
  copy open immediately to the right of the
  original tab?”, if you prefer the old placement.
- 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.
- 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.
- 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. Previously only the upload options
  and Paste Base64-Encoded Contents were offered
  there.
- Dropping several files into a session
  connected to a remote host now offers “Upload
  and Paste Paths”, which uploads them all and
  then types their remote paths onto the command
  line, as the single-file case already did.
  Files that fail to upload are left out of what
  is pasted. Local paths are no longer offered
  there, since they name nothing on that host.
- Fixed a source of main-thread stalls that built
  up in windows left open for days.
- 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.
  The Jobs tool had the same problem when you
  switched tabs, and is fixed too.
- “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.
- 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.
- Pasting a screenshot with “Upload and Paste
  Path” converts TIFF images to PNG and gives
  each one a unique name.
- The Script Console now keeps only the 200 most
  recent finished scripts and API connections.
  It used to keep every one, and tools that
  connect for each command, like it2, piled up
  enough of them to slow iTerm2 down over time.
- 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.
- Right-clicking a tab now offers Rename Tab,
  which opens the same dialog as double-clicking
  the tab.
- The Python API now has type annotations, so
  editors and type checkers such as mypy can
  check scripts that use it.
- “Support basic HTML tags in tab titles” now
  applies to pane title bars too, so <b>, <i>,
  and <u> render as styled text there instead of
  appearing literally. The window title shows the
  text without the tags, including titles set by
  a program or by tmux.

Bug Fixes:
- With KeePassXC as the browser password
  manager, “Remember this password” for a
  website’s sign-in dialog now saves the
  password instead of failing with “Could not
  save the password”.
- 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.
- Fixed a crash with the GPU renderer that
  could happen when switching to or from
  battery power.
- Fixed a crash from a Smart Selection rule
  with no regular expression, for example in a
  hand-edited or Dynamic Profile.
- Fixed memory corruption with the GPU renderer
  that could cause crashes after closing a
  session.
- Fixed a crash when iTerm2 was told to quit,
  for example by logging out, while it was
  still starting up.
- Fixed a crash when opening a new tab with a
  profile whose font could not be measured.
- Fixed a crash that could happen after closing
  a tab while an accessibility app, such as
  VoiceOver, was in use.
- Fixed a crash when splitting a pane that was
  closed or moved to another window before the
  split finished.
- Fixed crashes loading Dynamic Profiles files
  with values of an unexpected type, such as a
  null value or a Keyboard Map that is not an
  object. These values are now ignored or
  reported instead.
- Fixed a crash when holding Cmd with the mouse
  over a gray autosuggestion in the composer.
- Fixed a crash when exiting full screen in a
  window without a title bar, such as a tmux
  integration window, on macOS 26.
- Fixed a crash when splitting, maximizing,
  swapping, or rearranging panes in a tmux
  integration window that was left open after
  detaching.
- 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.
- 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.
- tmux integration no longer disconnects when a
  window has only floating panes (tmux 3.8 and
  later), and tolerates commands that fail
  because of a floating pane.
- Closing an archived session soon after opening
  it no longer warns that the session ended very
  soon after starting.
- Archiving split panes with the same name when
  their window closes no longer saves only one of
  them.
- Sessions closed shortly before quitting are
  now always archived if their profile archives
  sessions on close.
- iTerm2 no longer crashes when its app was
  moved, deleted, or replaced while running,
  for example by an installer. It now explains
  that the app is damaged and quits.
- Fixed “Export All Settings and Data” failing
  with “Failed to copy file” after using SSH
  Integration. Temporary connection files it
  could not copy are now skipped.
- Fixed a crash in “Import All Settings and
  Data” when the import changed a setting that
  requires your administrator password.
- Fixed a crash with the GPU renderer that
  could happen after changing the font size
  with Cmd-+ or Cmd--.
- Fixed a crash that could happen after using
  Find when the window got narrower before the
  match was highlighted.
- Fixed a crash that could recur every time
  iTerm2 saved window state. Quitting while a
  save was in progress could store duplicate
  entries in the saved-state database, which
  later saves could not handle.
- The crash reporter’s message fills in your
  name again instead of showing %%LONGUSERNAME
  and %%EMAILADDRESS.
- 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.
- 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.
- 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.
- “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 13092.
- 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.
- Fixed a rare race where a new session could
  silently lose some of the first output it
  received.
- 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.
- 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.
- 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.
- 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.
- The Session Status toolbelt tool now lists
  Claude Code sessions running inside tmux
  Integration panes, on this Mac or on a host
  reached through SSH Integration. They never
  appeared before, and their status went
  unreported to the rest of the app. Issue 13049.
- 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.
- 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.
- 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.
- 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.
- Fixed the Python API not reporting OSC 8
  hyperlinks. A cell’s CellStyle.url is now
  populated, so scripts reading screen contents
  can see the link target.
- Fixed the Python API reporting an underline
  color or a block ID on cells that did not have
  one. The first plain cell after a run that did
  was described as part of that run, so scripts
  reading screen contents saw the attribute
  spill past where it ended.
- 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.
- Fixed a crash when opening a new tab next to a
  tab in a tab group.
- Fixed a crash when editing a dynamic profile
  whose file had been changed on disk.
- Fixed a crash when clicking the bookmark
  button on a command in the terminal.
- Fixed a crash in the GPU renderer on macOS 26
  caused by configuring the drawable timeout off
  the main thread.
- Fixed a crash that could occur when quitting
  iTerm2 while several Claude Code sessions were
  running.
- Stopped falsely offering to reset the key
  reporting mode after every command when your
  shell manages the keyboard protocol itself,
  as Fish 4 and prompt tools like oh-my-posh do.
- 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.
- 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 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.
- Fixed a crash that could occur when a
  maximized (zoomed) split pane was torn
  down, such as when a tmux connection
  dropped.
- 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.
- 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
  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.
- 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 Kitty keyboard protocol base-layout
  reporting for non-US keyboard layouts, so
  shortcuts such as Ctrl-C work without
  switching layouts.
- 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 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.
- 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.
- 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.
- 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.
- Fixed a class of crash where Undo (⌘Z) after
  closing a session, split pane, or other view
  could crash.
- 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.
- 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.
- Fix 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 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.
- 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.
- When “Require Cmd modifier to drag text from
  a selection” is on, pressing on the selection
  without Cmd now starts a new selection instead
  of doing nothing. Without it, re-selecting
  from the first letter of a right-to-left line
  was a dead end, since the previous selection
  always sat under the click.
- 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 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.
- 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.
- Closing the Companion setup wizard while it
  is installing now cancels setup. Before, it
  kept going, asked for an administrator
  password with no explanation, and reopened
  the wizard if you declined. Cmd-W now closes
  the wizard instead of a terminal session.
- 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.
- Added a reply to CSI 16 t (report character
  cell size), which was previously ignored.
- 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 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.
- 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.
- 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.
- 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.
- 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.
- Fixed a crash when a program wrote a hyperlink
  whose web address contained an emoji or other
  character outside the Basic Multilingual
  Plane. The link is now handled correctly
  instead of terminating iTerm2, which could
  recur on restore because the saved session
  replayed the same link.
- 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 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.
- 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.
- 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.
- 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 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.
- Session status is now available in the
  session-scoped variable "status".
- 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 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.
- 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 Cmd-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.
- 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 crash when asking for notification
  permission. The reply block could be called
  after it had gone out of scope, which could
  terminate iTerm2 at the moment a
  notification was first requested.
- Fixed a lag when switching or closing tabs
  with the Session Status tool open in the
  toolbelt.
- 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.
- 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.
- 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.
- 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 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.
- 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.
- In Settings > General > Selection, the tooltip
  on “When pasting a dropped filename, wrap it in
  double quotes” now describes that setting. It
  used to repeat the tooltip for command
  selection. Issue 13086.
- 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.
- 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.
- 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.
- Resizing a window while only blank space was
  selected left behind an empty selection, so
  Copy and other selection-based actions acted
  on nothing.
- Restorable session state is again saved with
  SQLite’s write-ahead logging, which had been
  silently disabled. This reduces disk syncing
  while iTerm2 saves its state.
- The Bitwarden, KeePassXC, and Keeper password
  manager integrations now find command-line
  tools installed by Homebrew or npm. Homebrew’s
  “bw” is a script that needs “node”, which the
  integration could not locate, so Bitwarden
  showed an empty list. The password manager now
  runs these tools with your shell’s search path.
- 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.
- Debug logs no longer include passwords sent
  to or received from password manager
  integrations.
- Debug logs no longer include AI API keys or
  the values of custom HTTP headers configured
  for AI models.
- Password manager integrations that use a
  command-line tool, like Bitwarden’s “bw”, now
  find it on your shell’s search path instead
  of asking you to locate it. If you do pick it
  by hand, iTerm2 keeps the path you chose, so
  it keeps working after the tool is upgraded.
- 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.
- 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.
- 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, Workgroup
  toolbar, or a web browser’s back, forward, and
  reload buttons there, which made them
  impossible to click. A small arrow appears in
  the corner instead; click it to show or hide
  the window buttons. Issue 13100.
- 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.
- Fixed a crash that could happen when the GPU
  renderer had been running for several weeks
  without restarting iTerm2. Issue 13103.
- 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.
- 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.
- 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.
- Choosing Show Session Note from Open Quickly no
  longer makes an empty note appear briefly and
  then disappear.
- In tmux integration, a window created with
  “tmux new-window -d” now opens as a background
  tab instead of taking focus. Issue 13112.
- 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.
- 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.
- In tmux integration, iTerm2 could miss a
  window switch made by another tmux client or a
  script and keep showing the old tab.
- 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.
- Cancelling an upload over SSH Integration no
  longer leaves a temporary file on the remote
  host or gets stuck at “Cancelling…”.
- 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.
- Fixed hotkey windows with the Centered or
  “Centered, No Title Bar” style returning to
  their original size each time they were shown
  after you resized them.
- Fixed several Python API bugs. Subscribing to
  profile change notifications always failed,
  Color.hex failed for colors read from a
  profile, and items in nested submenus of
  MainMenu could not be reached on Python 3.12
  and earlier.
- Fixed Selection.async_get_string in the Python
  API adding line breaks that copying the same
  selection in iTerm2 leaves out.
- Editing or deleting an AI chat message now
  fully removes the later conversation, so the
  AI no longer sees leftover command results
  from it, and a message you deleted while the
  AI was still replying is no longer sent.
- 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.
- 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.
- 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.
- 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.
- 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.
- A trigger with no regex, such as an event
  trigger written by hand in a Dynamic Profile,
  no longer makes iTerm2 crash when a session
  is created.
- 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.
- A program that sets the window or tab title
  over and over no longer makes iTerm2 fall far
  behind, run at full CPU for minutes, and grow
  to gigabytes of memory.
- 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.
- 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.
- In an alert with a text field, such as the
  one asking how many spaces to use when you
  paste text with tabs, Cmd-A, Cmd-C, Cmd-V,
  Cmd-X, and Cmd-Z now edit the text instead of
  pressing a button. Also, two buttons in an
  alert no longer share the same Cmd-key
  shortcut.
- Fixed a memory leak where each AI request,
  such as each AI chat message, left memory in
  use until iTerm2 quit.
- Opening a file or link in a split pane while
  a tmux integration tab is current now opens
  it in a new tab. Before, it could take over
  the tmux pane’s place, vanish without
  running, or open a tmux shell pane without
  the link.
- The SetWindowFrame escape sequence no longer
  shrinks a window to nothing or moves it far
  off screen when given bad values. Malformed
  values are ignored, and the window now stays
  between its minimum size and the size of your
  displays, with part of it on a display.
- If a Python script that loads a URL in a
  browser session gave up waiting while you
  read the permission prompt, approving it
  afterward no longer loads the URL.
- An AI chat set to Llama (Local) no longer
  sends its messages to your cloud AI provider
  when no Ollama model is available, for example
  because Ollama isn’t running. It now shows an
  error asking you to start Ollama. Naming the
  chat and drawing its icon now also use the
  chat’s own model instead of the default one.
- Copying part of a line that mixes
  left-to-right and right-to-left text no
  longer drops the space between a
  left-to-right word and the right-to-left
  text after it, which fused the two words.
- The file iTerm2 keeps next to its saved
  window state (restorable-state.sqlite-wal)
  no longer stays as large as the saved state
  itself. With long scrollback it could use a
  lot of disk.
- When settings load from a custom folder,
  quitting no longer asks to copy settings to
  the folder every time just because iTerm2
  updated an older setting saved there.
- Pressing Cmd-Y (Edit > Engage Artificial
  Intelligence) in the Cockpit’s composer no
  longer crashes iTerm2. The menu item is now
  disabled there.
- With SSH integration, starting the remote
  shell, completing paths, and other requests
  with several long arguments no longer
  occasionally fail because the request was
  split in two on the way to the remote host.
- In the minimal theme, a browser tab’s tint now
  matches the page you just loaded. Before, it
  showed the previous page’s background color
  until the next page loaded.
- In the Cockpit’s composer, an @ followed by a
  space no longer keeps the session picker open,
  so Return inserts a newline instead of turning
  the @ into a session chip. After Esc closes the
  picker, it stays closed while you keep typing.
- Sending from the Cockpit after closing the tab
  of one of its chosen sessions now sends to the
  sessions that remain and names the closed one,
  instead of sending nothing and showing an
  internal identifier.
- The Cockpit no longer covers alerts attached
  to a terminal window, such as the Broadcast
  Input confirmation, hiding their buttons.
- The Cockpit composer’s placeholder text is no
  longer drawn twice when the panel is too
  narrow to fit it on one line.
- In the Cockpit’s composer, Advanced Paste and
  Open Command History, which did nothing there,
  are now disabled, and its help no longer lists
  them or AI lookup.
- Clicking the icon of the Current Directory,
  Hostname, or Username status bar component
  opens its menu again, as clicking its text
  does.
- The folder icon in the Current Directory
  status bar component can no longer be dragged
  after its directory has been deleted.
- With debug logging on, a password typed into
  a password field (for example, in the password
  manager) no longer appears in the debug log.
- Fixed saving a window arrangement with “Save
  to File with Contents…” and archiving a
  session when the text contained hyperlinks,
  underline colors, similar styling, or
  right-to-left text. The arrangement file was
  never written, and the session archive was
  empty. If either kind of save fails, iTerm2
  now reports an error. Issue 13143.

---

### Release Notes Guidelines (`docs/release-notes-guidelines.md`)

# Writing iTerm2 Release Notes

Release notes live in `docs/notes-<version>.txt` (cumulative, since the previous release) or `docs/notes-<version>beta<N>.txt` (per-beta diff, since the previous beta). They are **plain text, max 50 columns per line**.

## Audience

The reader is a **regular user or beta tester** who:

- Uses iTerm2 actively and reads release notes.
- Has **not** participated in development.
- Has **not** read the source code or commit history.
- Knows the names of features they already use, but not internal terminology.

Every entry must be self-explanatory to someone who only knows what iTerm2 looked like at the previous release.

## What to include

**Include:**

- Significant new features (a major UX change, a new panel, a new escape sequence, a new API).
- Improvements to existing features users will notice (faster, more options, better defaults, expanded scope).
- Bug fixes that affect features that **already shipped**.
- Performance, energy, and memory work that users will feel.

**Omit:**

- Implementation details (XPC re-org, internal symlinks, helper hooks, refactors, retain-cycle fixes that aren't user-visible).
- Advanced settings. These are off the beaten path and not release-note material.
- Developer-only features (env vars meant for switching between iTerm2 builds, etc.).
- Sub-features of a brand-new feature. If the parent is new, readers will discover the parts on their own.
- Bug fixes for features that are **also new in this release**. Readers have never seen the broken version; listing the fix just adds noise.

## Verify what's actually "new"

Before describing something as "improved", "now", "no longer", or "retuned", confirm the underlying feature existed in the previous release:

```sh
git ls-tree -r v<previous-tag> | grep -i <feature>
git grep -i <SymbolName> v<previous-tag>
```

If the feature itself shipped in this release, frame it as a brand-new capability — not as a refinement to something readers haven't seen.

This applies recursively: if `Workgroups` is new, then "the workgroup toolbar", "workgroup peers", "the Code Review prompt overlay", and any related concepts are also new — don't reference them as if they pre-existed.

## Section structure

Use these sections in order. Skip any that have no content.

1. **`Major New Features:`** — headline items. One short paragraph each: what it is and how to get to it. Don't enumerate every sub-feature.
2. **`New Features:`** — smaller new capabilities (a new escape sequence, a new panel, a new Python API, a new docking option). A new menu item that just exposes existing behavior is rarely a new feature.
3. **`Improvements:`** — tweaks to existing features (added options, better precision, broader scope). Energy, performance, and resource-usage wins also go here.
4. **`Bug Fixes:`** — fixes to behavior in features that pre-dated this release.

## Writing each entry

### Length

- One bullet, one idea. Aim for 1–3 lines.
- For a new feature, say **what it does** and **where to find it** (menu path, setting path, or escape sequence). Don't narrate the internal machinery or rationale.
- Don't paste the commit message. Rewrite from the reader's perspective.

### Voice

- Plain English. Avoid internal terminology. Examples of jargon to translate:
  - "right gutter" → "side panel" / "alongside the terminal"
  - "peer" → "companion session" / describe the relationship in prose
  - "in-session prompt overlay" → "the prompt panel that appears before…"
  - "cc-status hook", retain-cycle fixes, KVO refactors, XPC plumbing → omit
- Use **Title Case for feature names**: Smart Selection, Smart Selection Action, Workgroups, Code Review, Quick Look, Special Exceptions, Edit Session, Clippings, etc.
- Use **curly quotes** (`“ ”`, `‘ ’`) for user-visible quoted text. Use straight quotes only inside literal shell syntax (e.g. `$'…'`) or literal program output (e.g. `"unmatched '"`). Per CLAUDE.md, don't use `"` in user-visible strings except as the shorthand for inches.

### Order of introduction

If a bullet mentions feature X, X must already be familiar — either because it's pre-existing or because an earlier bullet introduced it. Put introductions before detail items.

For example, introduce the Clippings panel as a new feature **before** mentioning the `it2 add-clipping` subcommand that posts to it.

### Group like fixes

Collapse repetitive low-detail items:

- Multiple crash fixes → `- Fix various crashes.`
- Multiple memory leaks → `- Fix various memory leaks.`
- Multiple hangs → `- Fixed various hangs that could cause a beachball (X, Y, Z).`

## Format

- **50 columns** max. Hard-wrap.
- Bullets start with `- ` and continuation lines are indented two spaces.
- One blank line between sections.
- File names:
  - `notes-<version>.txt` — cumulative, relative to the previous stable.
  - `notes-<version>beta<N>.txt` — per-beta diff, relative to the previous beta.
- After creating a new notes file, `git add` it (per CLAUDE.md).

## Maintaining the cumulative file across releases

`docs/notes-3.7.txt` is the **running cumulative file** for the current
line: day to day you append new items to it, and it should contain
**only unreleased changes** — things that have not shipped in any
released build yet.

- **Never repeat a published item.** Once a change ships in a stable
  release (or a beta the reader may have installed), it must not appear
  in the notes for a *later* release. Re-listing it just tells readers
  about something they already have.
- **Trim on release.** When a release is cut, the items it shipped
  become published. Remove them from the cumulative file so what
  remains is only the still-unreleased work for the next version. The
  baseline for "already published" is the notes of the release that
  just shipped (its `notes-<version>.txt` / `notes-<version>beta<N>.txt`).
- **Nothing is lost by trimming.** Every shipped version's notes are
  preserved in its own per-version file and in git history. The
  cumulative file is a working set, not an archive.
- **Verify against what actually shipped, not against wording.** The
  cumulative file and the per-version files are written independently,
  so the same change is often worded differently in each. Decide what
  is published by the *commit* (is it in the shipped tag?), not by
  matching bullet text. A feature documented late can describe code
  that shipped in an earlier release — in that case the bullet is
  published even though the doc commit is recent. Split such write-ups:
  drop the parts already shipped, keep the parts that are new.

### Branches

- **master** accumulates all development; its `notes-3.7.txt` holds
  every unreleased item destined for the next build off master (whose
  version may not be known yet — a placeholder header is fine).
- **`release_x.y.z`** branches are cut from master and take
  cherry-picks. On a release branch, `notes-3.7.txt` should contain
  only what is new **since the previous release on that line** —
  typically just the handful of fixes cherry-picked for that patch.
- Because 3.7.x patch releases cherry-pick a subset of master's
  commits, "unreleased on master" is not simply "the newest commits."
  Compare by commit against the shipped tag (e.g. subject match or
  patch-id) to find what a release actually included.

## Pre-commit checklist

- [ ] Each entry makes sense without reading the source or commits.
- [ ] No internal jargon ("right gutter", "peer", "in-session overlay", hook names, etc.).
- [ ] No advanced-setting bullets.
- [ ] No developer-only bullets.
- [ ] No implementation-detail narration (internal machinery, refactors, rationale) — just what the reader observes.
- [ ] No item that already shipped in an earlier release; the cumulative file holds only unreleased changes.
- [ ] No bug-fix bullets for features that are themselves new in this release.
- [ ] Improvements are under `Improvements:`, not `Bug Fixes:`.
- [ ] Crashes / memory leaks / hangs are collapsed into single bullets.
- [ ] Feature names are Title Case.
- [ ] Curly quotes used everywhere except inside literal shell/error text.
- [ ] Every line ≤ 50 columns.

---

### Session Restoration And Process Reattachment (`docs/session-restoration-and-process-reattachment.md`)

# Session Restoration and Process Reattachment

This document explains how iTerm2 restores windows at launch and reconnects
restored sessions to the long-running child processes that survived across the
relaunch. The central mechanism is the *partial attachment*, which talks to the
multiserver daemon and obtains the surviving children's job managers up front,
before any session object exists.

## The problem

When iTerm2 quits and relaunches, the shells and programs it was running can
survive because they are owned by a separate daemon (the "multiserver"), not by
the iTerm2 process itself. On the next launch, iTerm2 wants to:

1. Recreate the windows, tabs, and split panes from the saved arrangement.
2. For each restored session, reconnect to its surviving process if it is still
   alive, or launch a fresh process if it is not.

Reconnecting requires contacting the daemon to learn which children are still
alive and to obtain a job manager for each. This has to happen *before* a
session's `PTYTask` can be created, because the restored session is then married
to the job manager the daemon handed back. So the daemon contact is necessarily
a prerequisite of building the sessions, not something done per-session as each
one is created.

The original implementation did this contact **synchronously**, blocking window
restoration on it. The problem (per commit `3dd3c5672`, "Implement partial
attachment"): "we used to block on attaching to the daemon. If the daemon is not
feeling well, it can take forever." A wedged or unhealthy daemon could hang the
entire restore. The partial-attachment design moves that contact out in front,
makes it asynchronous, and bounds it with a timeout: if the children do not come
back in time, restoration proceeds anyway and the stragglers are adopted as
orphans (commit `6692315a2`, "Restore children as orphans if it takes too long
to get them from the daemon").

## The two-phase attach

Attaching a session to a surviving process is split into two halves:

| Phase | When | What it does | Needs a `PTYSession`? |
|-------|------|--------------|-----------------------|
| **Partial attach** | Before any session exists | Connect to the daemon, ask whether the child PID is still alive, keep the connection and the answer | No |
| **Finish attach** | After the session is created | Register the process with the task system and wire up its file descriptors to the new session | Yes |

The split exists because the partial attach can be done *before* a `PTYSession`
exists, while the finish attach cannot (it needs the session's task to register
the process and wire up its file descriptors). Doing the daemon contact first,
asynchronously and under a timeout, means a slow or unhealthy daemon cannot hang
window restoration: it either completes in time or the children are adopted as
orphans.

### `iTermPartialAttachment`

The result of a partial attach is parked in an `iTermPartialAttachment`, a small
container holding everything the finish phase will need:

```objc
@protocol iTermPartialAttachment
@property (nonatomic, strong) id<iTermJobManagerPartialResult> partialResult;
@property (nonatomic, strong) id<iTermJobManager> jobManager;
@property (nonatomic, strong) dispatch_queue_t queue;
@end
```

- `jobManager` + `queue` - the live `iTermMultiServerJobManager` and its serial
  queue: the established connection to the daemon for that child.
- `partialResult` (an `iTermMultiServerJobManagerPartialAttachment`) - the facts
  learned by reaching the daemon: `attached` (did the child exist),
  `shouldRegister` (exists and has not terminated), `pid`, and a `brokenPipe`
  flag.

`iTermPartialAttachment` is **not** related to screen-content attachments
(images, URLs, ScreenChar arrays). It is purely about process reattachment.

**Key files:** `sources/Tasks/PTYTask.h` (protocol),
`sources/PTYSession/PTYSession+ARC.m` (implementation and creation),
`sources/Tasks/iTermMultiServerJobManager.m` (`partialResult` implementation,
`asyncPartialAttachToServer:`, `finishAttaching:task:`).

## The restoration flow

The window appears on screen **before** its sessions exist. Session objects are
not created until after the partial-attachment phase resolves, so by the time a
session exists, the attach-vs-launch decision is already determinate.

```
1. App launches.

2. bareTerminalWithArrangement:    ← empty window/frame, no tabs/panes/sessions.
   The window is handed back to AppKit here and becomes visible.

3. openPartialAttachmentsForArrangement:timeout:completion:
   Walks the arrangement DICTIONARIES only (no UI, no sessions). For each leaf
   session dict that has a SESSION_ARRANGEMENT_SERVER_DICT, kicks off an async
   partial attach to the daemon. Collects results into:
       { serverDict -> iTermPartialAttachment }

4. Wait for the daemon: all partial attaches complete, OR a timeout fires
   (advanced setting timeoutForDaemonAttachment, default 10 seconds).

5. completion(partialAttachments) -> loadArrangement:...partialAttachments:
   NOW the real UI and sessions are built, per tab:
     - tabWithArrangement: -> _recursiveRestoreSplitters: builds the skeleton
       of splitters and SessionViews (still no PTYSessions).
     - _recursiveRestoreSessions: -> sessionFromArrangement:...partialAttachments:
       instantiates each PTYSession into that skeleton and, on the spot, either
       finishes its partial attachment or launches a fresh process.
```

### Step 2: the window is created empty

`+[PseudoTerminal bareTerminalWithArrangement:forceOpeningHotKeyWindow:restoring:]`
lives up to its name: it builds the window/frame, geometry, and hotkey setup but
creates **zero** `PTYSession` objects. There are two callers:

- `PseudoTerminalRestorer.m` - the window-restoration entry point
  (`restoring:YES`). It builds the bare window and returns it through the
  restoration `completionHandler`, after which `asyncRestoreState:` drives the
  async partial-attachment phase. This is the path described here. (How this
  entry point is reached, and how it relates to Cocoa's own restoration, is
  covered in "Relationship to macOS state restoration" below.)
- `+[PseudoTerminal terminalWithArrangement:named:sessions:forceOpeningHotKeyWindow:]`
  (`restoring:NO`) - the synchronous "open a saved arrangement" path. It calls
  `loadArrangement:...partialAttachments:nil...` immediately, with no partial
  attachments (see "The synchronous path" below).

### Step 3: partial attachment walks dictionaries only

`openPartialAttachmentsForArrangement:` recurses through the saved arrangement as
plain dictionaries. `PTYTab`'s `_recursiveOpenPartialAttachments:` descends
`VIEW_TYPE_SPLITTER` -> `SUBVIEWS` and, at each leaf, calls
`+[PTYSession openPartialAttachmentsForArrangement:completion:]`, which extracts
the server dict, creates an `iTermMultiServerJobManager`, and calls
`asyncPartialAttachToServer:withProcessID:completion:`. **No view objects, tabs,
split panes, or sessions are created in this phase** - it produces only the
`{ serverDict -> iTermPartialAttachment }` dictionary.

The multiserver connection itself is established lazily here, on demand, via
`iTermMultiServerConnection.getOrCreatePrimaryConnectionWithCallback:`. The app
does not block on the daemon at launch; "before the daemon is connected to" is
the normal starting state of restoration, and this phase is exactly the
machinery that connects to it while the window is already up.

### Step 4: the timeout race

`-[PseudoTerminal openPartialAttachmentsForArrangement:timeout:completion:]`
races two things and calls back exactly once (guarded by a `haveNotified` flag):

- a `dispatch_group_notify` that fires when **all** tabs' partial attachments
  return, and
- a `dispatch_after` set to `iTermAdvancedSettingsModel.timeoutForDaemonAttachment`
  (default **10 seconds**).

Whichever wins calls `completion(result)` with whatever partial attachments
arrived so far. Any results that arrive **after** the timeout are routed to the
`timeout` block instead, which hands them to
`iTermOrphanServerAdopter.adoptPartialAttachments:`. Those late arrivals are
adopted into a new session rather than retrofitted onto the already-restored
session (see "Orphan adoption" below).

### Step 5: sessions are created, then attached or launched

Session creation happens inside the `completion` block, so `sessionFromArrangement:`
always runs with the partial-attachment dictionary already in hand. After it
computes `didAttach`:

- **`didAttach == YES`** (the session's `serverDict` was found and
  `tryToFinishAttachingToMultiserverWithPartialAttachment:` succeeded):
  `runCommand = NO`, and `startProgram:` is **never called**. The session adopts
  the surviving daemon child. The finish phase runs
  `iTermMultiServerJobManager.finishAttaching:task:`, which registers the process
  and wires up its file descriptors.
- **`didAttach == NO`** (no entry for this session, e.g. the daemon was too slow
  and the timeout fired): `runCommand` stays YES and `runCommandBlock(finish)`
  runs right there, building an `iTermSessionAttachOrLaunchRequest` and calling
  `[factory attachOrLaunchWithRequest:]`, which lands in
  `startProgramForRequest:` -> `startProgram:` and launches a fresh process.

There is no third "session exists but is parked waiting for a future attachment"
state. `PTYSession` does **not** defer `startProgram:`; the deferral is achieved
by not creating the session until the attach decision can be made synchronously.
`iTermSessionFactory.handleRealizedRequest:` has exactly three branches, all
synchronous: attach-to-server, finish-partial-attachment, or
`startProgramForRequest:`.

**Key files:** `sources/TerminalView/PseudoTerminal.m`
(`asyncRestoreArrangement:`, `openPartialAttachmentsForArrangement:timeout:completion:`,
`loadArrangement:`), `sources/TerminalView/PTYTab.m`
(`_recursiveOpenPartialAttachments:`, `tabWithArrangement:`,
`_recursiveRestoreSplitters:`, `_recursiveRestoreSessions:`),
`sources/PTYSession/PTYSession.m` (`sessionFromArrangement:`,
`tryToFinishAttachingToMultiserverWithPartialAttachment:`),
`sources/PTYSession/PTYSession+ARC.m`
(`+openPartialAttachmentsForArrangement:completion:`),
`sources/AppKit/iTermApplicationDelegate.m` (drives `asyncRestoreState:`),
`sources/Tasks/iTermOrphanServerAdopter.m` (late arrivals).

## Relationship to macOS state restoration

Everything above is the *session/process* layer. Sitting above it is the
question of who decides to restore a window in the first place. iTerm2 registers
with Cocoa's `NSWindowRestoration` machinery but, in its default configuration,
largely supplants it with its own controller. The OS's role is reduced to a GUID
handshake.

### Registration and encoding

Every terminal window opts into restoration and names `PseudoTerminalRestorer` as
its restoration class:

```objc
[[self window] setRestorable:YES];
[[self window] setRestorationClass:[PseudoTerminalRestorer class]];
```

On the encode side, `-[PseudoTerminal window:willEncodeRestorableState:]` runs
when macOS asks a window to serialize itself. With `storeStateInSqlite` (the
default) it writes only the window's GUID into the system coder
(`iTermWindowStateKeyGUID`) and keeps the real arrangement in iTerm2's own
database. Otherwise it stores the full arrangement into the coder under
`kTerminalWindowStateRestorationWindowArrangementKey` ("ptyarrangement", in
`iTermWindowImpl.m`'s `encodeRestorableStateWithCoder:`).

### Two restoration systems

- **macOS `NSWindowRestoration`** - at launch the system calls
  `+[PseudoTerminalRestorer restoreWindowWithIdentifier:state:completionHandler:]`
  for each window it remembers. That forwards internally to
  `...pseudoTerminalState:system:completionHandler:` with `system:YES`.
- **`iTermRestorableStateController`** - iTerm2's own SQLite-backed controller,
  on by default (`useRestorableStateController`). It holds the actual window
  state, including large scrollback via a `largeContentProvider`, and is what
  drives the async partial-attachment flow.

### How the system path is intercepted

At startup iTerm2 sets `ignoreSystemWindowRestoration` to mirror
`useRestorableStateController` (`iTermApplicationDelegate.m`). So when macOS calls
the restorer with `system:YES`, `PseudoTerminalRestorer` does **not** build a
window. It decodes the window GUID from the coder, parks macOS's
`completionHandler` in the controller keyed by that GUID
(`setSystemRestorationCallback:windowIdentifier:`), and returns
(`PseudoTerminalRestorer.m:154-169`). The system thus never restores a window
itself; it only hands iTerm2 a callback that must eventually be satisfied.

### How iTerm2 drives the real restoration

iTerm2's controller restores from its database and, per window, calls the same
restorer with `system:NO` (`iTermApplicationDelegate.m`). The `system:NO` call
skips the ignore short-circuit and runs the normal machinery: queue a block,
build a bare window, return it through the `completionHandler` early, then run
`asyncRestoreState:` for the slow daemon attach. As each window is produced, the
controller marries it to the parked macOS callback
(`iTermRestorableStateController.restoreWithSystemCallbacks:`) and invokes that
callback with the restored window, so the OS considers its restoration satisfied.
Any parked callbacks with no matching restored window are invoked with `nil`
(failure) via `invokeRemainingCompletionBlocksAsFailure`, so the OS is never left
waiting.

The net effect: macOS's coder-based restoration is reduced to a GUID handshake.
The GUID round-trips through the system coder so iTerm2 can match a system
callback to a window it restores from its own database; everything substantive
(the arrangement, the contents, the daemon reattachment) comes from iTerm2's
controller, not the `NSCoder`.

### Why calling the completion block correctly matters

Satisfying the `completionHandler(window, error)` is not just bookkeeping to
unblock the OS. The window object handed to that block is what macOS uses to
place the window: it puts it on the **Space** it was restored from and restores
its **fullscreen** state. If the block is never called, called with `nil`, or
called with the window before it is in the right state, the OS cannot do that
placement. So it is important to always call it, with the real restored window.

This is why fullscreen windows are a special case. For a non-fullscreen window
the restorer calls `completionHandler([term window], nil)` immediately. For a
window restoring into Lion fullscreen (`togglingLionFullScreen`), it instead
**defers** the call: it sets `gWaitingForFullScreen`, retains the handler, and
installs a `didEnterLionFullscreen` callback that calls
`completionHandler([theTerm window], nil)` only after the fullscreen transition
finishes, then resumes draining queued blocks (`PseudoTerminalRestorer.m:268-288`).
That ordering lets macOS see a window already in its fullscreen state and place
it on the correct Space.

### Batching and not blocking the OS

`PseudoTerminalRestorer` queues each window-creation block (`queuedBlocks`) and
drains them with `runQueuedBlocks`, pausing while a fullscreen transition is in
flight (`gWaitingForFullScreen`, as above). A post-restoration completion block
runs only once both the queued window blocks have all run and external
restoration has finished (`gExternalRestorationDidComplete` /
`runPostRestorationBlockIfNeeded`); that block restores buried sessions and kicks
off the orphan-server sweep (see "Orphan adoption"). Returning the window through
the `completionHandler` (early for normal windows, post-transition for fullscreen
ones) and doing the daemon attach asynchronously afterward is what keeps the slow
work from blocking the OS's restoration machinery.

### Precedence and opt-outs

Before any of the above, the restorer bails early in several cases
(`PseudoTerminalRestorer.m:138-204`):

- compare-rendering mode, the AppleScript test-app build, and unit tests complete
  with a `nil` window.
- `kPreferenceKeyOpenArrangementAtStartup`: restoration is aborted (complete
  `nil`), the arrangement's sessions are registered, and the saved default
  arrangement is opened instead (`iTermController.loadWindowArrangementWithName:`).
  The startup arrangement wins over restoration.
- `kPreferenceKeyOpenNoWindowsAtStartup`: abort and open nothing.

Above all of this, the OS-level "Close windows when quitting an app"
(`NSQuitAlwaysKeepsWindows`) still governs whether macOS attempts restoration at
all.

**Key files:** `sources/TerminalView/PseudoTerminalRestorer.m`
(`restoreWindowWithIdentifier:...system:completionHandler:`, `queuedBlocks`,
`runQueuedBlocks`, `runPostRestorationBlockIfNeeded`),
`sources/StateRestoration/iTermRestorableStateController.m`
(`setSystemRestorationCallback:windowIdentifier:`, `restoreWithSystemCallbacks:`,
`invokeRemainingCompletionBlocksAsFailure`),
`sources/TerminalView/PseudoTerminal.m` (`setRestorationClass:`,
`window:willEncodeRestorableState:`), `sources/TerminalView/iTermWindowImpl.m`
(`encodeRestorableStateWithCoder:`),
`sources/AppKit/iTermApplicationDelegate.m` (sets `ignoreSystemWindowRestoration`,
calls the restorer with `system:NO`, sets the post-restoration block).

## The `partialAttachments:nil` path is not daemon reconnection

`loadArrangement:` can also be called with `partialAttachments:nil`, via
`+[PseudoTerminal terminalWithArrangement:named:sessions:forceOpeningHotKeyWindow:]`.
It is tempting to think of this as "the synchronous restore path," but it is
**not** used for launch-time reconnection to surviving daemon children, and in
practice it does not marry a session to a preexisting process. Its callers are:

- **Undo close window/session** (`iTermApplicationDelegate.m`) - passes the
  closed window's *revived in-memory* `PTYSession` objects. The processes were
  terminated when the session closed.
- **Hotkey windows** (`iTermProfileHotKey.m`).
- **Loading a saved arrangement** from the menu (`iTermController.m`) - the
  arrangement was serialized to disk in an earlier run, so any daemon children
  it references are long gone.

In all of these, the `SESSION_ARRANGEMENT_SERVER_DICT` restoration identifiers do
not point at live daemon children, so the synchronous
`tryToAttachToMultiserverWithRestorationIdentifier:` attach fails and the session
relaunches (or is revived in-memory) instead of reconnecting. Launch-time
reconnection to a surviving process happens **only** through the partial-
attachment path; there is no synchronous reconnect path exercised during real
window restoration. (`-[PseudoTerminal restoreState:]`, which would call
`loadArrangement:...partialAttachments:nil`, has no callers and is dead.)

It is worth knowing that the job manager does expose a synchronous attach,
`attachToServer:withProcessID:task:`, but it merely wraps the same async partial
attach in a `dispatch_group_wait(..., DISPATCH_TIME_FOREVER)`. That blocking
behavior is exactly the "if the daemon is not feeling well, it can take forever"
hazard that the partial-attachment design was introduced to remove, which is why
the launch path no longer uses it.

Note also the subtle asymmetry within `sessionFromArrangement:`: the synchronous
attach branch only runs when `partialAttachments` is **entirely nil**. After a
timeout in the async path the dictionary is non-nil but missing the slow session,
so that session relaunches a fresh process rather than attempting a synchronous
attach.

## Orphan adoption

A surviving daemon child is *orphaned* when it is not claimed by any restored
session. Rather than leave such a process running with no window attached to it,
iTerm2 adopts it into a freshly created session so the user can get back to it.
There are two distinct paths into adoption, both handled by
`iTermOrphanServerAdopter` and both funneling through the same delegate hooks on
`iTermApplicationDelegate`.

### Path 1: late partial attachments (the restore timeout fallback)

This is the tail end of the flow described above. When the per-window restore
times out, any partial attachments that arrive *after* the deadline are routed
to the `timeout` block, which calls
`iTermOrphanServerAdopter.adoptPartialAttachments:`
(`iTermApplicationDelegate.m`, the `asyncRestoreState:timeout:` call site). Each
late `iTermPartialAttachment` is opened as a session via
`orphanServerAdopterOpenSessionForPartialAttachment:inWindow:`. The child is
real and its connection is already established; it simply missed the window that
was meant to host it, so it lands in an adopted session instead of its original
place in the arrangement.

### Path 2: the filesystem sweep for fully-orphaned servers

This catches processes whose owning session was never restored at all, for any
reason: the window was closed without saving, iTerm2 crashed, the arrangement
did not include the session, or a daemon outlived the app entirely.

At construction, `iTermOrphanServerAdopter` asynchronously scans the filesystem
for live server sockets:

- **Mono-servers** (the legacy one-process-per-server model) -
  `iTermOrphanServerAdopterFindMonoServers` looks in the file-descriptor
  directory for sockets matching the socket-name prefix.
- **Multi-servers** (the daemon model) -
  `iTermOrphanServerAdopterFindMultiServers` looks in Application Support for
  `iterm2-daemon-*.socket` (plus a transitional legacy glob).

After window restoration finishes, the post-restoration completion block calls
`openWindowWithOrphansWithCompletion:` (`iTermApplicationDelegate.m`). It waits
until either all window decodes are done (`numberOfDecodesPending == 0`) or the
`iTermDidDecodeWindowRestorableStateNotification` fires, so adoption never races
ahead of normal restoration and steals a child a window was about to claim. It
then connects to each discovered server and, for multi-servers, adopts the
connection's **`unattachedChildren`** - exactly the children no restored session
attached to - via `orphanServerAdopterOpenSessionForConnection:inWindow:`.

### What an adopted session looks like

Both paths open the process as a **new tab** under a default (`nil`) profile
through `iTermSessionLauncher` (`makeKey:NO`, `canActivate:NO`, so adoption is
non-intrusive), and then call `-[PTYSession showOrphanAnnouncement]` to tell the
user the session was adopted. An adopted session is **not** a restored session:
it reconnects to the live process but does not recover the arrangement's visual
state (scrollback contents, name, split-pane geometry), because that state lived
in the arrangement that timed out or was never saved.

The first adopted session creates a window (`iTermController.terminalWithSession:`)
and every later adoption joins that same `_window`, so all orphans from a launch
collect into one window rather than scattering.

**Key files:** `sources/Tasks/iTermOrphanServerAdopter.m`
(`adoptPartialAttachments:`, `openWindowWithOrphansWithCompletion:`,
`adoptMonoServerOrphanWithPath:`, `enqueueAdoptionsOfMultiServerOrphansWithPath:`,
`didEstablishMultiserverConnection:`), `sources/AppKit/iTermApplicationDelegate.m`
(`orphanServerAdopterOpenSessionForConnection:inWindow:completion:`,
`orphanServerAdopterOpenSessionForPartialAttachment:inWindow:completion:`, and
the post-restoration completion block that triggers the sweep).

## Is the partial attachment necessary?

For launch-time restoration, yes: reconnecting a restored `PTYSession` to a
surviving daemon child happens **only** through the partial-attachment path.
There is no synchronous path that does this during real window restoration (see
the section above), so this is not merely a faster way of doing something the
synchronous code also does.

What is genuinely optional is the *asynchronous, timed-out* shape of that path
rather than the reconnection itself. The job manager still contains a synchronous
attach that blocks on the daemon with `DISPATCH_TIME_FOREVER`; the old code used
it, and it would reconnect too - but it hangs window restoration whenever the
daemon is unhealthy. The partial-attachment design exists to get that
reconnection without the hang: do the daemon contact up front, asynchronously,
under a timeout, and fall back to orphan adoption when the children do not arrive
in time. That motivation is stated directly in commit `3dd3c5672`: blocking on
the daemon meant that "if the daemon is not feeling well, it can take forever."

So the honest framing is: the daemon contact must precede session creation (a
structural requirement), and doing it asynchronously under a timeout is what
keeps a sick daemon from wedging the launch - not a throughput optimization.

## Summary

| Question | Answer |
|----------|--------|
| Can a window be restored before the daemon is connected? | Yes, by design. The window appears empty first; the daemon connection happens during the partial-attachment phase. |
| When are session objects created? | After the partial-attachment phase resolves (or times out), inside `loadArrangement:`. Never at window creation. |
| Does `PTYSession` defer `startProgram:`? | No. Session creation itself is deferred; once a session exists, it attaches or launches synchronously. |
| What if the daemon is slower than the timeout? | The affected session relaunches a fresh process; the surviving process is later adopted into a separate orphan window. |
| Is `iTermPartialAttachment` necessary? | At launch, yes: it is the only path that reconnects a restored session to a surviving daemon child. What is optional is its async/timeout shape, which exists to keep an unhealthy daemon from hanging restoration. The synchronous `partialAttachments:nil` path is not used for launch reconnection and does not bind to a live process. |
| How does this interact with macOS state restoration? | iTerm2 registers `PseudoTerminalRestorer` as the windows' `NSWindowRestoration` class but, by default, intercepts the system path (`ignoreSystemWindowRestoration`) and parks the OS's callback. Its own SQLite-backed controller restores from its database, builds the windows, and satisfies the parked callbacks. The OS's coder role shrinks to a GUID handshake. |

---

### Uv Python Runtime Migration (`docs/uv-python-runtime-migration.md`)

# Replacing the bundled Python runtime with uv

## Status

Design + implementation plan. Investigation complete; decisions locked. Target: iTerm2
3.7.x (post-beta) or 3.8.

## Build process (per phase - follow strictly, in phase order)

Each phase is executed with TDD using this loop:

1. Write tests (failing first).
2. Implement.
3. Run tests; iterate until the code is correct and tests pass.
4. Launch a subagent to review the code.
5. Address its findings. Go to step 4 until no meaningful issues remain.
6. Proceed to the next phase.

Phases run in order: Phase 0 (notice beta) -> 1 (provisioner) -> 2 (provision/launch)
-> 3 (migration) -> 4 (target bump + enable) -> 5 (delete legacy).

### Development uv source (temporary)

Until the signed universal binary is hosted on iterm2.com with the min/max-macOS
manifest (a release-infra task owned by the author, needed before the Phase 4 beta),
use this URL for the uv download during development:

    https://releases.astral.sh/github/uv/releases/download/0.12.0/uv-aarch64-apple-darwin.tar.gz

Notes: this is an arch-specific (aarch64) `.tar.gz`, not the eventual `lipo`'d universal
binary, and it is served from Astral's mirror, not iterm2.com. Signature verification
and the manifest are stubbed/relaxed against this dev source and wired up for real when
hosting moves to iterm2.com.

## Motivation

iTerm2 ships a hand-built Python runtime (multiple CPython versions, the `iterm2`
module, and pyobjc as an optional extra) as an on-demand optional component. It is
built outside this repo by two sibling projects:

- `~/git/iterm2-pyenv` (orchestration scripts + built zips)
- `~/git/pyenv` (a fork of pyenv whose `python-build` compiles CPython and every C
  dependency from source, statically, to avoid depending on Homebrew/system libs)

Problems:

- The build is a pile of hacks: static readline 8.3 built privately, gdbm without
  readline, static openssl/sqlite/xz/zlib/zstd/gettext, `fdupes` hardlink dedupe,
  `templatize.sh` path rewriting (`__ITERM2_ENV__` / `__ITERM2_PYENV__`), an
  interactive `read`-gated pip dance run twice "to beat pip into submission,"
  RSA-signed delta zips.
- It is single-arch. The pyenv fork already flipped to arm64-only at runtime build
  80, and the app already splits manifests by OS (`manifest.json` vs
  `manifest-new.json`, `iTermAdvancedSettingsModel.m:940-948`).
- macOS 27 (September 2026) drops Rosetta 2, so an Intel binary can no longer run on
  Apple Silicon. Intel users (on macOS <=26) must still be supported, which today
  means maintaining the hack pipeline for that arch too.

The reason the runtime exists in the first place remains true: we cannot assume the
user has build tools or Homebrew.

## Why uv solves it

uv provisions Python from **python-build-standalone**, which ships **native x86_64
and native arm64** macOS binaries (deployment floors 10.9 / 11.0), with **no compiler
required** on the user's machine. That is exactly the constraint that motivated the
custom runtime, satisfied by prebuilt binaries. uv itself is a single standalone Rust
binary; it does not need Python to run.

Result: native Intel Python without Rosetta, native ARM Python from the same tool,
and the entire compile pipeline is deleted.

Key facts:

- uv env vars we rely on: `UV_PYTHON_INSTALL_DIR`, `UV_CACHE_DIR`,
  `UV_PYTHON_PREFERENCE=only-managed`, `UV_NO_CONFIG`, `UV_PYTHON_INSTALL_MIRROR`
  (deferred; see below), `UV_PYTHON_DOWNLOADS`.
- python-build-standalone offers CPython ~3.8 through ~3.14. 3.7 is not available.
- uv's own supported macOS floor is 13 (Ventura).

## Locked decisions

1. **uv delivery: download on demand.** A signed, `lipo`'d universal uv binary hosted
   on iterm2.com, fetched via the existing signature-verified optional-component
   downloader on first Python-scripting use. Not bundled in the app.
2. **Python source: upstream python-build-standalone** (uv's default). A self-hosted
   mirror via `UV_PYTHON_INSTALL_MIRROR` is a drop-in fallback for later; no
   architectural change needed to add it. Pin a known-good uv version rather than
   always pulling latest.
3. **Deployment target: bump the app from macOS 12 to macOS 13**, but only after a
   farewell-notice beta warns macOS 12 users (Phase 0), and the eventual 13-only build
   is gated by Sparkle `minimumSystemVersion` so 12 users are never offered an
   unlaunchable update. The bump itself is deferred to Phase 4 (paired with enabling
   uv). This meets uv's supported floor head-on and removes the need to verify uv on
   12. (The target was set to 12 in commit `8963d0574`, 2025-07-02.)
4. **Per-script metadata: keep `setup.cfg`.** Reuse `iTermSetupCfgParser` unchanged;
   drive `uv venv` + `uv pip install` from it. No pyproject.toml migration now.
5. **uv only at provision time; launch is a bare exec of the venv Python.** No uv
   process and no network on any script launch. uv (and the network) are involved
   only when creating, importing, migrating, upgrading, or editing dependencies.
   This makes "basic-script launch is offline" an architectural guarantee.
6. **Do not conflate uv artifacts with the legacy `iterm2env` names.** Old iTerm2
   builds locate a runtime by the exact strings `iterm2env` / `saved-iterm2env` and
   read `iterm2env-metadata.json`, expecting the pyenv tree layout. uv artifacts use
   new names so an old build sees "no runtime I recognize" and declines gracefully
   instead of misreading a flat venv.
7. **Forced Python-version remap only for 3.7.** python-build-standalone has no 3.7.
   Everything 3.8+ keeps its pinned minor. 3.7 is remapped to **3.9** (last release
   before the 3.10 `collections.abc` alias removal; negligibly riskier than 3.8 and
   avoids the one genuinely disruptive removal). Any forced minor bump is announced
   with a suppressible modal warning, not just a Script Console line.
8. **Drop `aioconsole`/`apython`.** stdlib `python -m asyncio` provides top-level
   `await` (since 3.8), which was apython's only real benefit.
9. **Gate the whole change behind an advanced setting; keep both code paths during
   development.** A boolean `iTermAdvancedSettingsModel` setting (proposed
   `pythonRuntimeUsesUV`, default NO) selects the runtime backend. Default NO means
   shipping `master` behaves exactly as today, so development happens on `master`
   without a long-lived branch. This is a genuine behavior toggle the user sets
   intentionally, so it is a normal (syncable) advanced setting, not `NoSync`. The
   legacy path and the uv path coexist until Phase 5 removes the setting and the
   legacy code together.

## Development gating (transitional)

The setting has an asymmetric effect, which is what makes both paths safe to coexist:

- **Provisioning branches on the setting.** Creating, importing, migrating, or
  editing dependencies of a script uses uv iff the setting is ON. With it OFF, all of
  these behave exactly as today (download the bundled runtime, pyenv tree layout, no
  auto-migration). uv is downloaded lazily on first provisioning, so users who never
  enable it never fetch it.
- **Launch branches on what is on disk, NOT on the setting.** A script with `.venv` +
  `python-runtime.json` launches via `exec .venv/bin/python`; a script with a legacy
  `iterm2env` launches the legacy way. So toggling the setting never strands an
  already-provisioned script - a uv-provisioned script keeps working even after the
  setting is turned back off (its launch is just a bare python exec and needs no uv),
  and a legacy script keeps working with the setting on until it is actually migrated.

Lifecycle of the default (there is no telemetry; rollout is by flipping the default,
not by measuring adoption):

1. Development: default NO, deployment target still macOS 12. `master` ships today's
   behavior; the author flips the gate on locally (on macOS 13+) to test. A separate
   Phase 0 notice beta warns macOS 12 users of the coming drop.
2. Beta: when ready, bump the deployment target to macOS 13 (Phase 4) and in the same
   beta flip the default to YES for all beta users. Both code paths still exist, so the
   gate remains an escape hatch - a user hitting a problem can turn it off and fall back
   to the legacy runtime. macOS 12 users are not offered this build (appcast
   `minimumSystemVersion`).
3. Stable: keep the default YES in stable releases.
4. Only after uv has shipped as the default through a beta cycle and into stable does
   Phase 5 remove the setting and the legacy path.

## What does NOT change (runtime swap, not a protocol change)

- The API transport and auth: unix socket at `.../private/socket`, TCP `localhost:1912`
  fallback, `api.iterm2.com` subprotocol, and the `ITERM2_COOKIE`/`ITERM2_KEY`
  cookie-and-key AppleScript handshake. `it2cli` and every existing `iterm2` client
  keep working. No wire break.
- The `iterm2` PyPI module (vendored at `api/library/python/iterm2/`) and its minimum
  library version gate 0.24 (`iTermWebSocketConnection.m:31`).
- The spaceless AppSupport symlink (`NSFileManager+iTerm.m:183`) — kept; venv shebangs
  still dislike spaces.
- `iTermSignatureVerifier` + `rsa_pub.pem` — repurposed to verify the uv binary.

## Target on-disk layout

All uv artifacts live under the spaceless Application Support symlink (venv shebangs
and interpreter symlinks dislike spaces), which the provisioner resolves via
`spacelessAppSupportCreatingLink()`:

- uv binary: `<spacelessAppSupport>/uv/bin/uv`
- uv-managed interpreters: `<spacelessAppSupport>/uv/python/` (`UV_PYTHON_INSTALL_DIR`)
- uv cache: `<spacelessAppSupport>/uv/cache/` (`UV_CACHE_DIR`)
- shared basic-script venvs: `<spacelessAppSupport>/uv/venvs/<minor>/`
- per full-env script venv: `<script>/.venv/` (flat `bin/python`)
- per full-env script marker: `<script>/python-runtime.json`, schema:
  `{ "schema": 1, "backend": "uv", "uv_version": "...", "python": "3.9", "remapped_from": "3.7" }`
  The `python` value records only the resolved minor (e.g. `3.9`), not a full patch
  version. Note: the marker is currently written non-atomically (`Data.write(to:)`
  without `.atomic`), a minor robustness gap (a crash mid-write could leave a truncated
  marker).

Launch contract (both kinds): `exec <venv>/bin/python <main.py> <args>` with
`ITERM2_COOKIE` / `ITERM2_KEY` / `SSL_CERT_FILE` in the environment. No uv, no network.

## Phase 0 - macOS 12 farewell notice beta (ships first, still supports macOS 12)

Bumping the deployment target strands macOS 12 users unless they get advance notice
first: their Sparkle updater would otherwise offer a 13-only build they cannot launch.
So the first shipped step is a notice, NOT the bump.

1. Release a 3.7.0 beta that still runs on macOS 12 (no deployment-target change) which
   shows macOS 12 users a notice: future 3.7.0 betas will require macOS 13 (Ventura) or
   later; this is the last beta that supports macOS 12. One-time / suppressible, stored
   under a `NoSync` flag.
2. This is decoupled from the uv work and requires none of it - ship it as early as
   possible to maximize advance notice.
3. Release-engineering plan for the eventual 13-only build (Phase 4): its Sparkle
   appcast entries must set `minimumSystemVersion = 13.0` so macOS 12 users are never
   offered an update they cannot launch and instead remain on this last-compatible
   beta. Confirm the shipping Sparkle honors per-item `minimumSystemVersion` (it does).

Exit: a beta is published warning macOS 12 users, and the appcast-gating plan for the
future 13-only build is confirmed.

Note on sequencing: Phases 1-3 (the uv implementation) are built with the deployment
target still at macOS 12 and the `pythonRuntimeUsesUV` gate defaulting OFF, so they can
merge to `master` and even ship in further macOS-12-compatible betas without affecting
anyone. The developer tests them locally on macOS 13+ with the gate on. The actual
target bump is deferred to Phase 4.

## Phase 1 - The uv provisioner (`sources/API/iTermUv`)

1. Acquire uv via the existing `iTermOptionalComponentDownloadWindowController`
   phases: download signed universal uv from `iterm2.com/downloads/uv/`, verify with
   `iTermSignatureVerifier` + `rsa_pub.pem`, install to `<spacelessAppSupport>/uv/bin/uv`,
   chmod +x.
2. Manifest is a list of entries, each
   `{ uv_version, url, signature, size, minimum_macos_version, maximum_macos_version }`.
   The app selects the newest entry whose macOS bracket includes the running OS and
   never downloads one outside it. This mirrors the legacy runtime manifest's
   `minimum_iterm_version`/`maximum_iterm_version` bracketing. Consequence: uv's
   supported macOS floor can never strand a user - a future uv that drops macOS 13 is
   simply not offered to macOS 13 users, who keep the last bracketed-compatible build.
   Each hosted uv version is pinned and tested; publishing a newer one in the manifest
   is how uv is rolled out (see the background-upgrade item below for how installed
   clients pick it up).
3. Provision-time environment (uv subprocesses only):
   - `UV_PYTHON_INSTALL_DIR = <spacelessAppSupport>/uv/python`
   - `UV_CACHE_DIR = <spacelessAppSupport>/uv/cache`
   - `UV_PYTHON_PREFERENCE = only-managed`
   - `UV_NO_CONFIG = 1`
   - `UV_PYTHON_DOWNLOADS = automatic`
4. Pure, testable arg builders: `venvArgs`, `pipInstallArgs`, `pythonListArgs`
   (discover which minors pbs offers).
5. `iTermUv` and the runtime downloader are defined behind protocols so tests can
   inject a fake (see Testing). Bake this into the API from the start.
6. Background upgrades (`iTermUvProvisioner.performPeriodicUpgradeCheck`, called once
   at launch, throttled to about daily by a persistent rate limit): if the manifest
   offers a newer uv than installed, silently download and atomically replace it
   (RSA-verified; the minimum-version floor still blocks downgrades). Then upgrade
   iterm2/certifi/pyobjc to the latest in every provisioned shared basic-script venv so
   basic scripts do not go stale. Full-environment scripts manage their own
   dependencies (setup.cfg / Dependency Editor) and are left untouched. Without this a
   first-installed uv would never be re-checked, so a broken uv could never be replaced.
   The same upgrade pair is also reachable on demand from Scripts > Manage: with the gate
   on, that item is "Install Python Runtime" (downloads uv and provisions the default
   shared venv) when uv is absent, and "Check for Updated Runtime" once installed, which
   calls `iTermUvProvisioner.userRequestedUpgradeCheck` to run the same upgrade
   bypassing the daily throttle and report the result (up to date / upgraded / failed).
   Because a corrupt binary reports version "unknown" (which loses to any manifest
   version), that item also repairs it. The title/target is decided in one place
   (`iTermScriptRuntime.pythonRuntimeMenuAction`) so a leftover legacy runtime never
   retargets the item at the legacy updater under the gate.

Exit: given a version, `iTermUv` produces a working venv with `iterm2` installed.

## Phase 2 - Provision and launch

### 2a. Full-environment scripts

- `iTermPythonRuntimeDownloader.installPythonEnvironmentTo:...` (`:865`): replace the
  hard-link + shebang-rewrite + serial `pip3` block with `uv venv` at `<script>/.venv`
  + `uv pip install` the setup.cfg deps (`iterm2` still force-added).
- Write `<script>/python-runtime.json` (schema above).
- Teach `environmentForScript:` / `populateScriptItem:` / the launcher interpreter
  lookup the new `.venv` + `python-runtime.json` names and the flat `bin/python`
  layout. Legacy is distinguished by "has `iterm2env`" vs "has `.venv` +
  `python-runtime.json`."

Current full-env layout (for reference):

    Scripts/MyScript/
      setup.cfg
      metadata.json          (optional)
      iterm2env/             recursive hard-link clone of the shared runtime
        versions/<X.Y.Z>/bin/python3
      MyScript/
        MyScript.py          the main script

Only `iterm2env/` changes (-> `.venv/`, plus the new `python-runtime.json`);
`setup.cfg` and the `MyScript/MyScript.py` source layout are untouched.

#### Disk sharing, interpreter retention, relocation

Today each script's `iterm2env` is a recursive hard-link clone of the shared runtime
(`installPythonEnvironmentTo:` -> `linkItemAtPath:`, `:891`), purely so N scripts cost
~1 runtime on disk (with copy-on-write for the few rewritten files, e.g. the pip3
shebang). uv preserves this dedup differently, and we DELETE the manual hard-link
machinery:

- Interpreter is shared, not copied: `.venv/bin/python` is a symlink to the single
  managed interpreter in `UV_PYTHON_INSTALL_DIR`. The interpreter lives once and every
  venv points at it.
- Package files are shared via uv's global cache using `UV_LINK_MODE` (default `clone`
  / APFS reflink on macOS, falling back to `hardlink`, then `copy`). Set
  `UV_LINK_MODE=clone` explicitly.

Consequences:

- Same-volume assumption: clone/hardlink needs the venv and cache on one volume. Both
  live under the user's home, so normally fine; a script on another volume falls back
  to `copy` (works, more disk).
- Interpreter retention: because venvs symlink the managed interpreter, never GC an
  interpreter minor while any venv references it. This replaces the current
  runtime-version GC (`finishInstallingRuntimeVersion:`) with a simpler "keep
  interpreters some venv uses" rule.
- Relocation: we create the venv with `uv venv --relocatable`, which writes relative
  paths into the console-script shebangs instead of baking in the absolute venv path.
  This keeps the venv working when the script folder is later moved or renamed (and
  lets the import path provision a venv and then move it into its final location). No
  templatization is needed (this is why `__ITERM2_ENV__` / `performSubstitutions:` can
  be deleted). Residual gap: `--relocatable` only handles a moved venv directory. A
  renamed home directory or a different machine still breaks the venv, because the
  interpreter is reached through an absolute symlink into `UV_PYTHON_INSTALL_DIR` and
  the `home` key in `pyvenv.cfg` is an absolute path. Those cases still require a full
  re-provision (cheap with uv).

### 2b. Basic scripts (shared venv, eager, offline launch)

- Maintain a shared venv per Python minor at `<AppSupport>/uv/venvs/<minor>/` with
  `iterm2` installed, provisioned eagerly (during the download/setup step, with
  progress UI). Launch execs its `bin/python` directly - guaranteed offline.
- `pathToStandardPyenvPythonWithPythonVersion:` returns the shared venv `bin/python`.
  A basic script's shebang selects the minor; provision that minor on first use if
  absent.

### 2c. REPL - drop aioconsole

- Replace bundled `aioconsole`/`apython` with stdlib `python -m asyncio` run against
  the shared venv, same cookie/key env (`iTermApplicationDelegate.m:3208`). The stdlib
  asyncio REPL prints a short "Use await directly" header that is not suppressible via
  a flag (`-q` does not silence it) and would require reimplementing CPython internals
  to blank across versions. Since iTerm2's injected `repl_banner.txt` already explains
  top-level await, we accept the extra header rather than maintain a fragile shim.

### 2d. Certs

- `uv pip install certifi` into each venv; set `SSL_CERT_FILE` = `certifi.where()`.
  Drop `SSL_CERT_DIR` (the openssl tree is gone). Localhost API traffic needs no TLS;
  this matters only for user scripts making HTTPS calls.

Exit: new full-env script, basic script, and REPL run end-to-end on 13; socket
round-trip + `import objc` verified; basic-script launch confirmed to make no network
call.

## Phase 3 - Migration of existing installs

- Detection: a script with a legacy `iterm2env` and no `python-runtime.json` is
  legacy and must be migrated. (No integer "generation" number - that would reuse the
  conflated metadata we are moving away from.)
- Timing: migrate LAZILY, per script, at launch time. A legacy script is migrated the
  first time it is launched (not when the gate flips), so flipping the gate on does not
  by itself rewrite anything on disk. To give the user advance notice, a predictive,
  one-time-per-launch startup warning scans all full-env scripts and, without invoking
  uv (it resolves against `iTermUvMigration.knownAvailableMinors`), computes which pins
  will need a forced minor bump and shows the one consolidated warning up front
  (`iTermScriptsMenuController maybeWarnAboutUvVersionBumps` +
  `iTermUvMigration.pendingVersionBumpWarning` / `forcedRemaps`). Consequence: the
  heads-up is predictive and batched, but the actual migration of any given script
  happens on its first launch. Newly imported/created scripts while the gate is on are
  provisioned via uv directly.
- Shebangs: no user-authored file is edited. iTerm2 never executes a script via its
  shebang - the launcher invokes the interpreter explicitly (`argumentsToRunScript:`
  -> `it2_api_wrapper.sh <venv-python> <script.py>`), so a `.py` shebang is used only
  by `inferredPythonVersionFromScriptAt:` for version inference. Basic-script shebangs
  are left stale-but-harmless (re-inferred and re-remapped each launch). Full-env main
  scripts are untouched. `setup.cfg` `python_requires` is ALSO left as-is: the
  authoritative resolved version for a uv script is the `python-runtime.json` marker
  (which records `python` and `remapped_from`), and re-resolving a stale pin is
  idempotent (3.7 re-resolves to 3.9 again), so rewriting setup.cfg would only risk
  clobbering the user's other setup.cfg content for no behavioral gain. The one shebang
  rewrite done today
  (`replaceShebangInScriptAtPath:`, `:913`, fixing the pip3 console-script) is deleted
  outright - uv writes correct absolute shebangs for `.venv/bin/*` itself.
- Rebuild-with-rollback: reuse `upgradeFullEnvironmentScriptAt:` (`:62`) structure -
  move `iterm2env` -> `saved-iterm2env`, provision `.venv` via uv from setup.cfg,
  restore on failure. `saved-iterm2env` remains understandable to old builds if the
  user downgrades.
- Version remap (minimal, compatibility-preserving):
  - Preserve the pinned minor whenever pbs offers it (discover via `uv python list`).
  - Auto-bump patch only (e.g. 3.9.2 -> newest 3.9.x); no warning.
  - Force a minor bump only when pbs has no build for that minor - in practice only
    3.7 -> 3.9. The resolved version is recorded in `python-runtime.json`; setup.cfg
    and shebangs are left as-is (inference/re-resolution is idempotent).
- Warning for forced minor bumps (real dialog, not just console):
  - One consolidated modal `iTermWarning`, shown once at startup (predictively, before
    any script is actually migrated), listing every affected script
    (e.g. `"MyScript": 3.7 -> 3.9`), not one dialog per script.
  - Body states plainly that Python minor versions are not guaranteed source-
    compatible, so a script may need changes.
  - Permanent stifle via `iTermWarning`'s suppression identifier under a
    `NoSync`-prefixed key ("Don't warn me about Python version changes again").
  - Also log per-script old->new to the Script Console as the durable record.
- Migration only runs when the `pythonRuntimeUsesUV` gate is ON (see "Development
  gating"). With the gate OFF, legacy scripts are left untouched and run the legacy
  way.
- Escape hatch / fallback: if uv provisioning fails but an installed legacy
  `iterm2env` still runs, fall back to it rather than hard-failing (in addition to the
  gate, which can be turned off entirely).

Exit: a legacy full-env script, a legacy basic script (incl. a 3.7 one triggering the
warning), and a signed `.its` import all migrate and run; rollback verified by forcing
a failure.

## Phase 4 - Bump deployment target to macOS 13 and enable uv in beta

This is where macOS 12 is actually dropped. It is deferred to here (not Phase 0)
because uv requires macOS 13, so the target bump and enabling uv ship together, and
only after the Phase 0 notice beta has given macOS 12 users warning.

1. `iTerm2.xcodeproj/project.pbxproj`: `MACOSX_DEPLOYMENT_TARGET` 12 -> 13, all targets
   (mirror commit `8963d0574`; redo its dep rebuild if needed).
2. Update the two CLAUDE.md "deployment target is macOS 12" lines to 13.
3. Simplify now-dead `@available(macOS 13, *)` branches opportunistically.
4. In the SAME beta, flip the `pythonRuntimeUsesUV` default to YES (rollout step: beta
   default ON). uv needs macOS 13, so the bump and the enable are one release.
5. The 13-only Sparkle appcast entries set `minimumSystemVersion = 13.0` (planned in
   Phase 0); macOS 12 users, already warned, are not offered this build.
6. Build + `make run` smoke test on macOS 13.

Exit: a macOS-13-only beta ships with uv on by default.

## Phase 5 - Delete the old machinery (follow-up release)

Trigger: after uv has shipped as the default (gate YES) through a beta cycle and into
stable releases. There is no telemetry; this is a deliberate decision once uv-by-
default has proven out in the field, not a measured-adoption threshold.

- Make uv unconditional: delete the `pythonRuntimeUsesUV` advanced setting and the
  legacy provisioning path it guarded (the launch path already keys off on-disk state,
  so it needs only the legacy branch removed).
- Remove manifest complexity: `parsedManifestFromInputStream:` version/OS filtering,
  the site-packages delta fields, the `manifest.json` / `manifest-new.json` split
  (`iTermAdvancedSettingsModel.m:940-948`).
- Remove pyenv-tree code: `performSubstitutions:` (`__ITERM2_ENV__` / `__ITERM2_PYENV__`),
  `createDeepLinkTo:`, `pythonVersionsAt:` / `bestPythonVersionAt:`,
  `finishInstallingRuntimeVersion:` GC.
- Keep `iTermSignatureVerifier` + `rsa_pub.pem` (now verifies the uv binary).
- Retire the external build repos (`~/git/iterm2-pyenv`, the `~/git/pyenv` fork);
  stop hosting runtime zips once no supported iTerm2 fetches them.
- Reclaim uv disk (deferred from the review as [20.1]): there is currently no GC of uv
  interpreters, shared venvs, or `UV_CACHE_DIR` (only the orphaned `saved-iterm2env`
  from an interrupted migration is reclaimed, at launch). Add a retention pass that
  runs `uv cache prune`, keeps only interpreter minors still referenced by a shared
  venv or a full-environment marker, and removes the rest. It lands here because it is
  coupled to removing the legacy runtime and needs a referenced-interpreter policy so
  it never deletes one a script still uses.

## Testing

Integration and end-to-end tests are the primary way this change is derisked. The
riskiest parts are not the branching logic (easy to unit test) but the real chain:
uv provisions a native interpreter with no compiler -> installs iterm2/pyobjc from
wheels -> a launched script connects over the socket, authenticates, and drives the
API. The tests are tiered so the fast, hermetic ones run by default and the
network/hardware-bound ones run in a separate harness (mirroring the existing
`tools/run_ai_live.sh` split).

### Fidelity principle: the real uv carries the derisking

Testing orchestration against a FAKE uv only proves our code handled the result we
pretended uv returned. The actual risk here is uv's real contract - its venv layout,
exit codes, error text, interpreter selection - so a fake can pass while the real thing
drifts. Therefore the migration flow, provisioning, and rollback are all exercised
against the REAL uv (offline, against a primed pinned cache) in Tiers B/C, including a
real rollback triggered by a genuinely failing input (a bogus dependency, which fails
deterministically), not a simulated failure.

The split below is about WHICH suite, not fake-vs-real: the default ModernTests run
must be hermetic, zero-setup, and network-free, so it holds only what needs no uv at
all plus a few narrow seam-based checks. Everything needing real fidelity runs in the
harness.

- Pin an exact uv version and exact Python patch versions for reproducibility. Use a
  persistent `UV_CACHE_DIR` + `UV_PYTHON_INSTALL_DIR` so harness runs are warm and can
  run `--offline` after one priming run.
- Network-bound tests tolerate slowness (generous timeouts, retry transient network)
  per the no-flaky-tests rule and live outside the default ModernTests run.

### Tier A - hermetic tests (ModernTests, default run, no network, no real uv)

- Remap decisions (pure functions, no uv): preserve pinned minor, patch-only
  auto-bump, forced 3.7 -> 3.9, over an in-memory available-versions list.
- Launch-command construction: bare `exec <venv>/bin/python` with no uv in the command,
  correct env (`ITERM2_COOKIE`/`ITERM2_KEY`/`SSL_CERT_FILE`), backend selection driven
  by on-disk state (`.venv` vs `iterm2env`) independent of the gate.
- Downgrade-safety invariant: a migrated layout has no `iterm2env` +
  `iterm2env-metadata.json` an old build would misread.
- Consolidated warning batching: one warning listing many affected scripts + the
  suppression key. Uses the injectable seam to avoid provisioning N real envs; it
  simulates provisioning EFFECTS and does NOT assert exact uv arg strings (brittle,
  low value).

The injectable `iTermUv` seam exists only for these narrow cases (scale, isolation,
and precise failure injection where useful) - not as a fidelity substitute for the
real uv, which covers the full migration path in Tiers B/C.

### Tier B - provisioning integration (new harness, network, no full app)

`tools/run_python_runtime_e2e.sh` (modeled on `run_ai_live.sh`), scoped by filter:

- Download + verify the signed uv binary.
- `uv venv` + install `iterm2 pyobjc certifi` with `--only-binary=:all:` - a passing
  install PROVES no compiler is needed for the standard dep set.
- Assert: `.venv` layout + `python-runtime.json`; `import iterm2`; `import objc` plus
  touching an AppKit symbol; `certifi.where()` resolves; selected Python version;
  and native arch (`platform.machine()` == host, no Rosetta).

Status: implemented as `tools/run_python_runtime_e2e.sh`, which wraps the
`ITERM2_UV_LIVE`-gated `UvProvisionerLiveTests` (it sets `TEST_RUNNER_ITERM2_UV_LIVE`
so xcodebuild forwards the opt-in). `testFetchManifestDownloadVerifyInstall` covers the
download + RSA verify + install + `uv --version`; `testProvisionFullEnvironmentEndToEnd`
covers `uv venv` + wheels-only install of iterm2/certifi/pyobjc, the `.venv` +
`python-runtime.json` assertions, the selected version, and `import iterm2, certifi,
objc, AppKit` with `certifi.where()`. The explicit native-arch assertion is not yet
automated (the interpreter is a native universal2 build; arch is checked manually per
the plan below).

### Tier C - end-to-end: a script drives the API (flagship)

Stand up a real API endpoint and run a provisioned script against it:

- Server: instantiate `iTermAPIServer` in-process bound to a temp socket (it is a
  plain instantiable class) and wire it to a test request handler that records/asserts
  the API operations the script performs. No full-app launch needed.
- Provision a script whose body connects via `iterm2` and performs an observable op
  (set a user variable / register an RPC / create a window), launch it through the real
  `iTermAPIScriptLauncher`, and assert the op landed and the child exited 0.
- Transport assertions: cookie/key auth accepted; unix socket path resolution and the
  TCP 1912 fallback; an HTTPS fetch inside the script succeeds (validates
  `SSL_CERT_FILE`/certifi).
- Offline relaunch: block network, relaunch, assert success (launch is a bare exec)
  and that the cache is untouched.
- Real migration (real uv): take a legacy full-env script fixture, run the ACTUAL
  migration, then assert the migrated script connects and drives the API. Include a
  real rollback case (migrate with a bogus dependency) asserting `iterm2env` is restored
  from `saved-iterm2env` and the script still runs.
- Matrix: basic / full-env / migrated-legacy x fresh-cache / warm-cache, gate ON; plus
  a gate-OFF run asserting the legacy path still works, and a toggle run (provision via
  uv, flip gate OFF, assert it still launches).

Status: partially automated. The provisioning-and-import slice (provision a full
environment with real uv, confirm the interpreter imports iterm2/certifi/objc/AppKit,
and that `python -m asyncio` supports the top-level await the REPL needs) runs in
`testProvisionFullEnvironmentEndToEnd` via `tools/run_python_runtime_e2e.sh provision`.
The rest of the flagship - an in-process `iTermAPIServer` driven by a launched script,
the transport/offline-relaunch assertions, the real migration + bogus-dependency
rollback, and the full cache/gate matrix - is not yet automated and is exercised by
`tests/uv-migration-manual-test-plan.md`. Building it out as in-process XCTests remains
future work; until then the manual plan is the source of truth for that coverage.

### Tier D - cross-architecture

Run tiers B and C on both an Intel and an Apple Silicon Mac on macOS 13. The Intel run
proves the core motivation (native x86_64 Python, no Rosetta).

### Follow-up

Done: CLAUDE.md now has the workflow note (like the `run_ai_live.sh` one) directing that
changes to the uv provisioner / launcher / migration run
`tools/run_python_runtime_e2e.sh`.

## Risks

- uv's macOS floor: RESOLVED by the manifest's `minimum_macos_version` /
  `maximum_macos_version` fields (Phase 1) - the app only ever downloads a uv build
  whose bracket includes the running OS, so a future uv that drops an older macOS is
  never offered to those users.
- python-build-standalone longevity: accepted. GitHub is not going away in the relevant
  horizon; the self-hosted mirror (`UV_PYTHON_INSTALL_MIRROR`) remains a drop-in
  fallback if that ever changes.

No open decisions remain. (3.7 -> 3.9 confirmed; in-process `iTermAPIServer` for Tier C
confirmed; injectable `iTermUv` seam is a settled Phase 1 constraint; wheel-only install
of iterm2/pyobjc/protobuf/websockets is covered by Tier B.)

---

### Vm Qa (`docs/vm-qa.md`)

# Hands-on QA on a test VM

Unit tests can't tell you whether focus lands where a user expects, whether a panel appears in the
right place, or whether a drag feels right. For changes like that, drive a real build on a test VM
with synthetic mouse and keyboard events, read the accessibility tree, and take screenshots.
`tools/vm/qa.sh` does all of this over ssh, so nothing happens on the developer's Mac.

Never use accessibility APIs, synthetic events, or screenshots on the developer's own Mac. Only on
a VM they've given you.

## Before you start

Ask the developer which VM to use, every time. There are only a few and they decide who gets
which. Don't pick one yourself, don't fall back to a default, and don't assume a VM from an
earlier session is still yours. Every `qa.sh` command takes `--host VM` as its first argument.

The VM must already be provisioned for tests (`tools/vm/provision_test_vm.sh`, which only the
developer runs), and the developer's GUI session must be logged in.

Privacy permissions are granted by the developer at the VM's screen, once per VM. In System
Settings > Privacy & Security (on macOS 27, input control is listed under “Device Control and
Data Access” rather than Accessibility):

| What | Needs | Grant it to |
| --- | --- | --- |
| Pointer, keyboard, reading focus, menus, window frames | Accessibility | `/usr/libexec/sshd-keygen-wrapper` (ssh sessions) |
| Screenshots | Screen Recording | `~/iterm2-vm-test/qa/vmctl` |

`tools/vm/qa.sh --host VM setup` prints which of these are missing. To make vmctl show up in the Screen
Recording list, run `tools/vm/qa.sh --host VM sh '~/iterm2-vm-test/qa/guirun.sh ~/iterm2-vm-test/qa/vmctl request'`.
vmctl is signed with the local Apple Development identity, so the grant survives rebuilding it.
Ask the developer for missing grants with exact paths, and do everything that doesn't need them
first.

Features that need a plugin (inline AI chat needs iTermAI.app, browser panes need
iTermBrowserPlugin.app) need it installed in the VM's /Applications. Ask the developer.

## The loop

With the VM the developer gave you (`vm2` here):

```
q() { tools/vm/qa.sh --host vm2 "$@"; }
q setup                  # once per session: install vmctl, check permissions
q build                  # build this working tree on the VM
q build-baseline         # optional: build origin/master to compare against
q sh defaults write qa FocusFollowsMouse -bool true
q launch                 # or: launch baseline
q ctl click 600 500
q ctl type "echo PANE_A"
q ctl key 36             # Return
q f                      # what has focus?
q shot                   # screenshot to tmp/vm-qa/shot.png; read it
q clean                  # when done
```

`tools/vm/qa.sh --host VM` with no command lists every command. The vmctl commands are listed at the top
of `tools/vm/qa/vmctl.swift`.

Write the QA plan before testing: numbered cases with steps and expected results. Track results
case by case, and note how each was tested when it wasn't the way a user would do it.

## Builds and settings

- The candidate runs with settings suite `qa` and the baseline with `qa-baseline`, so neither
  touches the other's settings or the test runner's. A new suite starts with default settings.
- Write settings only while the app is quit (`qa.sh quit`); a running app overwrites them. Then
  `launch`. Keys are the ones in `iTermPreferences.m`. Advanced settings use the name in
  `iTermAdvancedSettingsModel.m`, capitalized (`StealKeyFocus`).
- Profiles live in the suite's “New Bookmarks” array. Edit them with `qa.sh profiles SUITE ...`
  after the suite's first launch, e.g. `qa.sh profiles qa add Web 'Custom Command="Browser"'
  'Initial URL="https://example.com"'`, then open it from Shell > Split Vertically… by typing
  its name.
- `launch` turns off window restoration, quits leftover session servers, hides other apps, and
  gives the app one window at 100,80 sized 1400x800 (`--frame` changes it).
- `build-baseline [REF]` builds a git ref in a separate VM checkout. The first one is a full
  build. Use it to decide whether a finding is a regression: run the same steps on both builds.
- A test run on the same VM (`tools/run_tests_vm.sh`) quits every iTerm2 it built, including
  yours. Don't run tests on the VM you're doing hands-on QA on; ask the developer for another
  VM or wait.

## Driving the app

- Coordinates are global screen points, top-left origin. `shot` prints how to turn image pixels
  back into points. Get this right before clicking on something you saw in a screenshot: the
  image is scaled, so its pixels are not points.
- `ctl move` glides through intermediate points like a real pointer, so tracking areas fire.
  `ctl down`, `ctl drag`, `ctl up` make drags.
- `ctl type` types ASCII with key codes for a US layout. Common key codes: Return 36, Tab 48,
  Escape 53, Delete 51, arrows 123 (left) 124 (right) 125 (down) 126 (up), and with `cmd`: D 2,
  F 3, N 45, T 17, W 13, ] 30.
- System shortcuts such as Control-arrow (Spaces) and Control-up (Mission Control) need
  `ctl chord KEYCODE`, which sends a real Control key press.
- `qa.sh menu Shell "Split Vertically…"` presses menu items by title. Use the real `…`.
- Any argument that is exactly `PID` is replaced with the app's process id: `ctl wins PID`,
  `ctl setframe PID f 100 80 800 600`.
- To make iTerm2 inactive, activate another app: `ctl activate com.apple.finder`.
- `ctl flick DY` sends a trackpad scroll with momentum. It's synthetic, so treat it as weaker
  evidence than a real trackpad and say so in results.

## Checking results

- Echo a marker like `PANE_A` in each pane. `qa.sh f` shows the focused element and the last
  `PANE_` marker in its text, so you can tell panes apart without a screenshot.
- The accessibility tree can lag or be stale (web views, the AI chat input). Confirm anything
  that matters by typing a unique word and checking where it appeared, in a screenshot or with
  `f`.
- `ctl watchfocus PID 40` samples focus for two seconds to catch flicker.
- `qa.sh log` prints the app's stdout and stderr. For temporary diagnostics, add `NSFuckingLog`
  lines, rebuild, and read them there. Remove them before committing.
- Debug logging through the app's menu may be blocked by the agent's permission checker; the log
  above is the reliable route.

## Cleaning up

`qa.sh clean` quits the apps and deletes the QA settings suites, the baseline checkout and its
build, and the tools on the VM. It doesn't undo changes made through the UI: remove extra Spaces
in Mission Control (`ctl chord 126`, hover the Space, click its close button) and say what's
left over when you report.

---

### Companion Docs Notes (`Companion/docs/notes.txt`)

iTerm2 Buddy (Companion App) Release Notes

New Features:
- When iTerm2 on your Mac shows a warning and
  waits for an answer, iTerm2 Buddy now shows it
  and lets you answer from your phone, including
  “don’t ask again” when the warning offers it
  and any value the warning asks you to fill in.
  That includes password prompts, such as a
  password manager’s master password or an scp
  password: you can type the password on your
  phone.
  A warning that isn’t holding up the Mac stays
  out of your way unless it’s about the session
  you’re viewing; the session list marks sessions
  with a warning waiting.
  Before, the app lost its connection until
  someone dismissed the warning at the Mac. If the
  Mac is showing a dialog that can’t be answered
  from the phone, a banner says so and the app
  waits instead of disconnecting. Requires an
  up-to-date Mac.
- iTerm2 Buddy now works with Macs that have AI
  turned off. You can pair for terminal viewing
  and control alone: browse sessions, watch live
  output, and type from your phone. Chat surfaces
  appear with a short explanation of how to turn
  AI on, and re-enable live if you do.
- Long-press one of your own messages in a chat
  to delete it and everything after it. The
  change syncs to the Mac and any other device in
  the same chat. Chats also now update live when
  you edit or delete a message on the Mac.
- You can now turn on push notifications from
  your phone. After pairing, iTerm2 Buddy asks
  whether you want to receive notifications, and
  you can turn them on later under Settings while
  paired. Previously this was only reachable by
  enabling alerts on the Mac.

Bug Fixes:
- The QR scanner used to show a black preview the
  first time you granted camera access (notably on
  iOS 26), because the camera was set up before
  permission was granted. It now waits for access
  before configuring the camera, so the live
  preview appears right away.
- Opening a session that has already closed now
  shows a “Stream Ended” message explaining the
  session is gone, instead of a blank black
  screen that never loaded.
- The chat button at the top of a session view
  now keeps the session view underneath the
  conversation it opens, so Back returns you to
  the session (on the same tab) instead of
  jumping to the Chats home screen.
- Emailing your diagnostic logs now attaches a
  single compressed .zip instead of one
  attachment per log file, so large logs actually
  send instead of exceeding the mail composer's
  limits.
- Picking a session for a new session-bound chat
  now shows the same window > tab > pane list as
  the Sessions tab and the @-mention picker,
  instead of a flat, unorganized roster. Each row
  has an eye button to preview the session before
  committing to it, like the @-mention picker.
- Fast-scrolling a session's scrollback no longer
  floods the relay with tile requests, which
  could trip its rate limit and silently drop
  the connection to the Mac while the phone still
  looked connected. Requests are now paced.
- Reply and approval notifications are more
  reliable. A turn that pauses for your approval
  no longer leaves the typing indicator spinning,
  a command awaiting your Allow/Deny now shows as
  an action you need to take, and a chat you
  reconnect to mid-reply still notifies you when
  the reply arrives.
- When the keyboard is open over a live session,
  the bottom lines (where you’re typing) can now
  scroll up above it instead of hiding behind it,
  and the key bar’s background fills the gap at
  the keyboard’s rounded corners.
- Settings is now reachable before you pair - a
  gear on the launch screen opens it - so you can
  email diagnostic logs even when not connected
  to a Mac. It also moved off the chat list into
  a sheet you can open from anywhere.
- Pairing and reconnecting no longer fail on
  networks that can’t reach iterm2.com. The app
  now falls back to a second copy of its server
  list. If neither can be reached, it says so
  and suggests another network instead of
  showing “The request timed out.” Tap Details
  to see each server it tried and what went
  wrong with each.

New Features:
- You can now type into a live session. Tap the
  terminal to bring up the keyboard. An accessory
  bar adds Esc, Control-C, Tab, and arrow keys,
  and expands to sticky Control and left/right
  Option dead-keys, function keys, and Home / End
  / Page Up / Page Down / Delete. Left and right
  Option follow how the session's profile is
  configured to treat each Option key. When a
  selection is showing, a single tap clears it
  instead of opening the keyboard. Requires an
  up-to-date Mac.
- A composer for sending longer input to a live
  session. Tap the compose button in the keyboard
  bar to open a local editor above the keyboard,
  write or edit your text, then Send types it to
  the session all at once. Editing locally avoids
  the round-trip lag of sending each keystroke.
  Tapping the keyboard’s dictation mic opens the
  composer too, so you can speak a command and
  review it before it runs. The composer also has
  its own on-device dictation mic that transcribes
  entirely on your phone, with no cloud and no
  cost. Send does not add a Return, so press the
  Return key if you want one.
- The live session view now fills the screen edge
  to edge, behind the translucent nav bar, tab
  bar, and the dynamic island in landscape.
  Content still rests within the safe area, and
  you can scroll any line or column out from
  behind them.
- A session-bound chat now has a button in the
  toolbar that jumps to its session view. The
  button appears only while the bound session is
  still open; unbound and orchestrator chats never
  show it.
- Full-screen apps (vim, less, htop, and other
  programs that take over the screen) now look
  right in the live session view: the phone hides
  the scrollback behind them and shows only the
  app's own screen. When such an app has mouse
  reporting on, a scroll gesture pages the app
  instead of scrolling nowhere. While zoomed in,
  a drag pans the view and only starts sending
  scroll to the app once you reach the top or
  bottom edge. Requires a Mac new enough to
  support it.
- A resize button in the live session view fits
  the terminal grid to your phone for readable
  text: 40 columns in portrait, 80 in landscape,
  and as many rows as fill the screen. The Mac
  resizes the real session to match.
- You can now mute a chat so it stops sending
  push notifications to your phone. Swipe right
  on a chat in the list, or use the bell button
  in the conversation, to mute or unmute it. A
  muted chat shows a slashed bell in the list.
  The Mac remembers the choice, so pushes stay
  off even while your phone is unreachable.
  Requires a Mac new enough to support it; the
  controls are hidden otherwise.
- Message the agent from the live session view.
  A compose button opens an overlay with a text
  field and a voice-dictation mic, so you can send
  a message without leaving the session. If you
  reached the view by tapping an @-mention in a
  chat, your message goes to that chat; otherwise
  it goes to a session-bound chat. When the agent
  finishes replying, a notification appears;
  tapping it opens the chat, and Back returns you
  to the session view.
- Before your first send from a live session,
  iTerm2 Buddy can ask “Share This Session with
  the AI?” so the terminal's state and visible
  screen are included with your messages only
  after you allow it.
- Live session view. When the paired Mac is new
  enough, tapping a session shows live video that
  updates as the terminal changes, instead of a
  static snapshot. The Mac renders and streams the
  visible screen, so inline images, colors, and
  full-screen apps look exactly as on the Mac. A
  quiet terminal uses almost no data, the view
  stays current on a slow connection instead of
  lagging behind, and it pauses itself with a
  notice if it would use too much data.
  Scroll up to browse the session's scrollback,
  pinch to zoom in, and drag with one finger to
  pan around. Press and hold to start selecting
  text; drag the handles (with a magnifier) to
  adjust the selection, then Copy, Select All, or
  Paste from the menu. Selection works both in the
  live screen and in scrollback, and a selection
  made on the Mac shows on the phone too. The view
  follows along when you resize the Mac window.
  Older Macs keep showing the static snapshot.
- iTerm2 Buddy now asks for notification
  permission when you turn on sending terminal
  alerts to your iPhone on the Mac, instead of
  only when an AI chat needs them.
- The app now rotates to landscape on iPhone,
  not just portrait. (iPad already supported
  all orientations.)
- Messages from a newer iTerm2 that this app
  doesn't understand now show a placeholder
  ("You need a newer version of iTerm2 Buddy to
  view this message") instead of silently
  disappearing or hiding the rest of the chat.
- When you @-mention a session or workgroup in
  an orchestration chat, the agent is granted
  standing permission to control it without a
  prompt. A notice with a Revoke button lets
  you take that permission back.
- Voice dictation in chat. Tap the microphone
  in the message bar to dictate instead of type.
  The first time, it offers to download a small
  speech model; after that, tapping the mic
  starts listening right away.
  Transcription runs entirely on your device
  using Whisper, with no cloud and no cost. Your
  audio never leaves the phone.
  While listening you get a level meter and
  haptic feedback, the text appears as you speak,
  and tapping send (or leaving the chat) stops
  recording.
  Choose or remove the speech model under
  Settings > Voice Input. Only models that run
  well on this device are listed.
- The session picker for @-mentions now shows
  the same window, tab, and pane hierarchy as
  the Sessions tab. Each row has a preview
  button to view the session before picking it;
  tapping elsewhere on the row selects it.
- New chats now default to Orchestration on the
  New Chat screen.

Bug Fixes:
- A session you linked to a chat or opened from
  an @-mention keeps working after its shell
  restarts on the Mac (common with the Claude
  Code integration), instead of going dead and
  showing “That session no longer exists.”
- Dictation no longer occasionally leaves the
  microphone recording, or wedges the mic button,
  when quickly switching between composer bars
  while starting or finishing a dictation.
- Double-tapping Send while dictating no longer
  shows a false "dictation was interrupted"
  message after the message was actually sent.
- A reply notification no longer occasionally
  drops a leading character (such as a minus
  sign) from the continued part of a long reply.
- A message sent from a session view that fails
  to send no longer leaves a duplicate, never-
  delivered bubble behind, even if you switch
  chats while it is sending. It comes back to
  the composer so you can retry.
- Reply notifications on the lock screen now
  always show session and workgroup names, even
  when the name appears late in a long reply.
- After a reconnect, an open chat no longer
  briefly reverts to an older set of messages,
  dropping ones that arrived since you last
  left it.
- Reply notifications for a session no longer
  keep firing after you leave it in the case
  where the same session was open in two
  places at once.
- The connection now recovers from brief network
  drops (like switching Wi-Fi or a spotty signal)
  instead of getting stuck on "Reconnecting." The
  Mac no longer becomes unreachable after a blip
  until it is relaunched.
- @-mentions in command-request messages now
  show the friendly session or workgroup name
  instead of the raw identifier.
- @-mentions in chat messages that could not be
  resolved (for example while the Mac was briefly
  unreachable) now show the session name once the
  connection recovers or you reopen the chat,
  instead of staying a raw identifier.
- In the Sessions tab, a split pane that hosts a
  peer workgroup now shows as a container with
  its own icon and is no longer tappable. Only
  the individual peers beneath it open a session.
- The relay servers moved to new addresses.
  Pairing now requires an up-to-date iTerm2
  and iTerm2 Buddy; a version that predates
  the move shows an update prompt instead of
  pairing into a state that cannot receive
  notifications.
- When the relay hits its daily data limit,
  the app now shows a “Daily data limit
  reached” banner with a Reconnect now
  button and stops retrying every couple
  seconds until the limit resets.
- The zoom button in the session view now
  switches to 60 columns in portrait and 120
  in landscape, instead of 40 and 80, for a
  more useful amount of content.
- Settings now shows this pairing’s relay
  room name in hex when paired, so you can
  match it against your Mac and the relay
  logs when reporting a problem.
- Pairing moved to the new sharded relay
  resolver. Pairing with an older Mac is
  refused with a prompt to update it. A
  pairing made before the move must be made
  again.

---

### Api Library Python Iterm2 Docs Alert (`api/library/python/iterm2/docs/alert.rst`)

Alert
-----------
.. automodule:: iterm2.alert
.. autoclass:: iterm2.Alert
   :members: title, subtitle, add_button, async_run
.. autoclass:: iterm2.TextInputAlert
   :members: title, subtitle, placeholder, default_value, window_id, async_run
.. autoclass:: iterm2.PolyModalAlert
   :members: title, subtitle, text_field, checkboxes, checkbox_defaults, combobox_items, combobox_default, add_button, add_checkboxes, add_checkbox_item, add_combobox, add_combobox_item, add_text_field, set_width, async_run

----

Indices and tables
==================

* :ref:`genindex`
* :ref:`search`

---

### Api Library Python Iterm2 Docs App (`api/library/python/iterm2/docs/app.rst`)

App
---
.. automodule:: iterm2.app
.. autofunction:: iterm2.async_get_app
.. autofunction:: iterm2.async_invoke_function
.. autoclass:: iterm2.App
   :members: async_activate, pretty_str, get_window_by_id, get_tab_by_id, get_session_by_id, get_window_for_tab, current_terminal_window, get_window_and_tab_for_session, terminal_windows, async_get_variable, async_set_variable, buried_sessions, broadcast_domains, async_get_theme, async_move_session, async_apply_layout
.. autoclass:: iterm2.app.CreateWindowException



----

Indices and tables
==================

* :ref:`genindex`
* :ref:`search`

---

### Api Library Python Iterm2 Docs Arrangement (`api/library/python/iterm2/docs/arrangement.rst`)

Arrangement
-----------
.. automodule:: iterm2.arrangement
.. autoclass:: iterm2.Arrangement
   :members: async_save, async_restore, async_list
.. autoclass:: iterm2.arrangement.SavedArrangementException



----

Indices and tables
==================

* :ref:`genindex`
* :ref:`search`

---

### Api Library Python Iterm2 Docs Broadcast (`api/library/python/iterm2/docs/broadcast.rst`)

Broadcast
---------
.. automodule:: iterm2.broadcast
.. autoclass:: iterm2.BroadcastDomain
   :members: add_session, sessions
.. autofunction:: iterm2.async_set_broadcast_domains

----

Indices and tables
==================

* :ref:`genindex`
* :ref:`search`

---

### Api Library Python Iterm2 Docs Color (`api/library/python/iterm2/docs/color.rst`)

Color
-----
.. automodule:: iterm2.color
.. autoclass:: iterm2.Color
   :members: red, green, blue, alpha, color_space
.. autoclass:: iterm2.ColorSpace
   :members: SRGB, CALIBRATED

----

Indices and tables
==================

* :ref:`genindex`
* :ref:`search`

---

### Api Library Python Iterm2 Docs Colorpresets (`api/library/python/iterm2/docs/colorpresets.rst`)

Color Presets
-------------
.. automodule:: iterm2.colorpresets
.. autoclass:: iterm2.ColorPreset
   :members: async_get_list, async_get, values
.. autoclass:: iterm2.ColorPreset.Color
   :members: key
.. autoclass:: iterm2.ListPresetsException
.. autoclass:: iterm2.GetPresetException



----

Indices and tables
==================

* :ref:`genindex`
* :ref:`search`

---

### Api Library Python Iterm2 Docs Connection (`api/library/python/iterm2/docs/connection.rst`)

Connection
----------
.. automodule:: iterm2.connection
.. autofunction:: iterm2.run_until_complete
.. autofunction:: iterm2.run_forever
.. autoclass:: iterm2.connection.Connection
   :members: async_create

----

Indices and tables
==================

* :ref:`genindex`
  * :ref:`search`

---

### Api Library Python Iterm2 Docs Customcontrol (`api/library/python/iterm2/docs/customcontrol.rst`)

Custom Control Sequences
------------------------
.. automodule:: iterm2.customcontrol
.. autoclass:: iterm2.CustomControlSequenceMonitor
   :members: async_get

----

Indices and tables
==================

* :ref:`genindex`
* :ref:`search`

---

### Api Library Python Iterm2 Docs Focus (`api/library/python/iterm2/docs/focus.rst`)

Focus
-----

.. autoclass:: iterm2.FocusMonitor
   :members: async_get_next_update

.. autoclass:: iterm2.FocusUpdate
   :members: application_active, window_changed, selected_tab_changed, active_session_changed

.. autoclass:: iterm2.FocusUpdateActiveSessionChanged
   :members: session_id

.. autoclass:: iterm2.FocusUpdateSelectedTabChanged
   :members: tab_id

.. autoclass:: iterm2.FocusUpdateWindowChanged
   :members: window_id, event

.. autoclass:: iterm2::FocusUpdateWindowChanged.Reason
   :members: TERMINAL_WINDOW_BECAME_KEY, TERMINAL_WINDOW_IS_CURRENT, TERMINAL_WINDOW_RESIGNED_KEY

.. autoclass:: iterm2.FocusUpdateApplicationActive
   :members: application_active



----

Indices and tables
==================

* :ref:`genindex`
* :ref:`search`

---

### Api Library Python Iterm2 Docs Index (`api/library/python/iterm2/docs/index.rst`)

.. iTerm2 Python API documentation master file, created by
   sphinx-quickstart on Sun Apr 29 22:07:35 2018.
   You can adapt this file completely to your liking, but it should at least
   contain the root `toctree` directive.

Python API
==========

iTerm2 offers a Python package which makes it easy to write Python scripts that
control iTerm2 and extend its behavior.

**Getting Started**

Start by following the :ref:`tutorial <tutorial-index>`, which gives a step-by-step guide to writing a
script and describes the architecture of iTerm2's scripting system.

**Example Code**

Visit the :ref:`examples <examples-index>` for a collection of working examples.

.. toctree::
   :maxdepth: 1
   :caption: Class reference:

   alert
   app
   arrangement
   broadcast
   color
   colorpresets
   customcontrol
   connection
   focus
   keyboard
   lifecycle
   mainmenu
   preferences
   profile
   prompt
   registration
   screen
   selection
   session
   statusbar
   tab
   tabgroup
   tmux
   tool
   transaction
   util
   variables
   window

----

Indices and tables
==================

* :ref:`genindex`
* :ref:`search`

---

### Api Library Python Iterm2 Docs Keyboard (`api/library/python/iterm2/docs/keyboard.rst`)

Keyboard
--------

Provides classes for monitoring keyboard activity and modifying how iTerm2 handles keystrokes.

.. autoclass:: iterm2.KeystrokeMonitor
  :members: async_get

.. autoclass:: iterm2.KeystrokeFilter

.. autoclass:: iterm2.Keystroke
  :members: characters, characters_ignoring_modifiers, modifiers, keycode

.. autoclass:: iterm2.KeystrokePattern
  :members: required_modifiers, forbidden_modifiers, keycodes, characters, characters_ignoring_modifiers

.. autoclass:: iterm2.Modifier
  :undoc-members:
  :members:

.. autoclass:: iterm2.Keycode
  :undoc-members:
  :members:

----

Indices and tables
==================

* :ref:`genindex`
* :ref:`search`

---

### Api Library Python Iterm2 Docs Lifecycle (`api/library/python/iterm2/docs/lifecycle.rst`)

Life Cycle
----------
.. automodule:: iterm2.lifecycle
.. autoclass:: iterm2.SessionTerminationMonitor
   :members: async_get
.. autoclass:: iterm2.LayoutChangeMonitor
   :members: async_get
.. autoclass:: iterm2.NewSessionMonitor
   :members: async_get
.. autoclass:: iterm2.EachSessionOnceMonitor
   :members: async_foreach_session_create_task, async_get

----

Indices and tables
==================

* :ref:`genindex`
* :ref:`search`

---

### Api Library Python Iterm2 Docs Mainmenu (`api/library/python/iterm2/docs/mainmenu.rst`)

Main Menu
---------
.. automodule:: iterm2.mainmenu
.. autoclass:: iterm2.MainMenu
   :members: async_select_menu_item, async_get_menu_item_state
.. autoclass:: iterm2.MenuItemException
.. autoclass:: iterm2.MenuItemState
   :members: checked, enabled

----

Indices and tables
==================

* :ref:`genindex`
* :ref:`search`

---

### Api Library Python Iterm2 Docs Menu Ids (`api/library/python/iterm2/docs/menu_ids.rst`)

:orphan:

Menu Item Identifiers
---------------------

To refer to a menu item you must use its unique identifier. This table shows the identifier for each menu item.

----------


^^^^^^^^^^^
Identifiers
^^^^^^^^^^^


======================================================================================= ==============================================================================
Menu Item                                                                               Identifier                                                                    
======================================================================================= ==============================================================================
iTerm2 > About iTerm2                                                                   `About iTerm2`                                                                
iTerm2 > Show Tip of the Day                                                            `Show Tip of the Day`                                                         
iTerm2 > Companion Device Settings                                                      `Pair Companion Device`                                                       
iTerm2 > Suppressed Alerts…                                                             `Suppressed Alerts`                                                           
iTerm2 > Check for Updates…                                                             `Check For Updates…`                                                          
iTerm2 > Toggle Debug Logging                                                           `Toggle Debug Logging`                                                        
iTerm2 > Save Retrospective Debug Logs                                                  `Save Retrospective Debug Logs`                                               
iTerm2 > Copy Performance Stats                                                         `Copy Performance Stats`                                                      
iTerm2 > Capture GPU Frame                                                              `Capture Metal Frame`                                                         
iTerm2 > Check Settings Control Truncation                                              `Check Settings Control Truncation`                                           
iTerm2 > Preferences…                                                                   `Preferences...`                                                              
iTerm2 > Hide iTerm2                                                                    `Hide iTerm2`                                                                 
iTerm2 > Hide Others                                                                    `Hide Others`                                                                 
iTerm2 > Show All                                                                       `Show All`                                                                    
iTerm2 > Secure Keyboard Entry                                                          `Secure Keyboard Entry`                                                       
iTerm2 > Make iTerm2 Default Term                                                       `Make iTerm2 Default Term`                                                    
iTerm2 > Make Terminal Default Term                                                     `Make Terminal Default Term`                                                  
iTerm2 > Install Shell Integration                                                      `Install Shell Integration`                                                   
iTerm2 > Install Claude Code Integration                                                `Install Claude Code Integration`                                             
iTerm2 > Reinstall Claude Code Integration                                              `Reinstall Claude Code Integration`                                           
iTerm2 > Uninstall Claude Code Integration                                              `Uninstall Claude Code Integration`                                           
iTerm2 > Remove Recent Profiles from Dock Menu                                          `Remove Recent Profiles from Dock Menu`                                       
iTerm2 > Quit iTerm2                                                                    `Quit iTerm2`                                                                 
Shell > New Window                                                                      `New Window`                                                                  
Shell > New Window with Current Profile                                                 `New Window with Current Profile`                                             
Shell > New Tab                                                                         `New Tab`                                                                     
Shell > New Tab Next to Current Tab                                                     `New Tab Next to Current Tab`                                                 
Shell > New Tab with Current Profile                                                    `New Tab with Current Profile`                                                
Shell > Duplicate Window                                                                `Duplicate Window`                                                            
Shell > Duplicate Tab                                                                   `Duplicate Tab`                                                               
Shell > Split Horizontally with Current Profile                                         `Split Horizontally with Current Profile`                                     
Shell > Split Vertically with Current Profile                                           `Split Vertically with Current Profile`                                       
Shell > Split Horizontally…                                                             `Split Horizontally…`                                                         
Shell > Split Vertically…                                                               `Split Vertically…`                                                           
Shell > Save Contents…                                                                  `Log.SaveContents`                                                            
Shell > Save Selected Text…                                                             `Save Selected Text…`                                                         
Shell > Close                                                                           `Close`                                                                       
Shell > Close Terminal Window                                                           `Close Terminal Window`                                                       
Shell > Close All Panes in Tab                                                          `Close All Panes in Tab`                                                      
Shell > Undo Close                                                                      `Undo Close`                                                                  
Shell > Archives > Save Archive of Current Session…                                     `Save Archive of Current Session`                                             
Shell > Archives > Restore Archive…                                                     `Restore Archive`                                                             
Shell > Workgroups > Exit Workgroup                                                     `Exit Workgroup`                                                              
Shell > Broadcast Input > Send Input to Current Session Only                            `Broadcast Input.Send Input to Current Session Only`                          
Shell > Broadcast Input > Broadcast Input to All Panes in All Tabs                      `Broadcast Input.Broadcast Input to All Panes in All Tabs`                    
Shell > Broadcast Input > Broadcast Input to All Panes in Current Tab                   `Broadcast Input.Broadcast Input to All Panes in Current Tab`                 
Shell > Broadcast Input > Toggle Broadcast Input to Current Session                     `Broadcast Input.Toggle Broadcast Input to Current Session`                   
Shell > Broadcast Input > Show Background Pattern Indicator                             `Broadcast Input.Show Background Pattern Indicator`                           
Shell > Broadcast Input > Current Session is Broadcast Source                           `Broadcast Input.Current Session is Broadcast Source`                         
Shell > Toggle Buffer Input                                                             `Toggle Buffer Input`                                                         
Shell > tmux > Detach                                                                   `tmux.Detach`                                                                 
Shell > tmux > Force Detach                                                             `tmux.Force Detach`                                                           
Shell > tmux > New Tmux Window                                                          `tmux.New Tmux Window`                                                        
Shell > tmux > New Tmux Tab                                                             `tmux.New Tmux Tab`                                                           
Shell > tmux > Pause Pane                                                               `trmux.Pause Pane`                                                            
Shell > tmux > Dashboard                                                                `tmux.Dashboard`                                                              
Shell > ssh > Disconnect                                                                `ssh.Disconnect`                                                              
Shell > ssh > Download Files…                                                           `ssh.Download Files`                                                          
Shell > ssh > Remote host can control iTerm2                                            `ssh.Remote host can control iTerm2`                                          
Shell > Page Setup…                                                                     `Page Setup...`                                                               
Shell > Print > Screen                                                                  `Print.Screen`                                                                
Shell > Print > Selection                                                               `Print.Selection`                                                             
Shell > Print > Buffer                                                                  `Print.Buffer`                                                                
Edit > Undo                                                                             `Undo`                                                                        
Edit > Redo                                                                             `Redo`                                                                        
Edit > Cut                                                                              `Cut`                                                                         
Edit > Copy                                                                             `Copy`                                                                        
Edit > Copy with Styles                                                                 `Copy with Styles`                                                            
Edit > Copy with Control Sequences                                                      `Copy with Control Sequences`                                                 
Edit > Copy Mode                                                                        `Copy Mode`                                                                   
Edit > Paste                                                                            `Paste`                                                                       
Edit > Paste Special > Advanced Paste…                                                  `Paste Special.Advanced Paste…`                                               
Edit > Paste Special > Paste Selection                                                  `Paste Special.Paste Selection`                                               
Edit > Paste Special > Paste File Base64-Encoded                                        `Paste Special.Paste File Base64-Encoded`                                     
Edit > Paste Special > Paste Slowly                                                     `Paste Special.Paste Slowly`                                                  
Edit > Paste Special > Paste Faster                                                     `Paste Special.Paste Faster`                                                  
Edit > Paste Special > Paste Slowly Faster                                              `Paste Special.Paste Slowly Faster`                                           
Edit > Paste Special > Paste Slower                                                     `Paste Special.Paste Slower`                                                  
Edit > Paste Special > Paste Slowly Slower                                              `Paste Special.Paste Slowly Slower`                                           
Edit > Paste Special > Warn Before Multi-Line Paste                                     `Paste Special.Warn Before Multi-Line Paste`                                  
Edit > Paste Special > Limit Multi-Line Paste Warning to Shell Prompt                   `Paste Special.Limit Multi-Line Paste Warning to Shell Prompt`                
Edit > Paste Special > Prompt to Convert Tabs to Spaces when Pasting                    `Paste Special.Prompt to Convert Tabs to Spaces when Pasting`                 
Edit > Paste Special > Warn Before Pasting One Line Ending in a Newline at Shell Prompt `Paste Special.Warn Before Pasting One Line Ending in a Newline at Shell Prompt`
Edit > Replace Selection > Render Selection Natively                                    `Render Selection Natively`                                                   
Edit > Replace Selection > Replace with Pretty-Printed JSON                             `Replace Selection.Replace with Pretty-Printed JSON`                          
Edit > Replace Selection > Replace with Base 64-Encoded Value                           `Replace Selection.Replace with Base 64-Encoded Value`                        
Edit > Replace Selection > Replace with Base 64-Decoded Value                           `Replace Selection.Replace with Base 64-Decoded Value`                        
Edit > Engage Artificial Intelligence                                                   `Engage Artificial Intelligence`                                              
Edit > Explain Output with AI                                                           `Explain Output with AI`                                                      
Edit > Open Selection                                                                   `Open Selection`                                                              
Edit > Jump to Selection                                                                `Find.Jump to Selection`                                                      
Edit > Select All                                                                       `Select All`                                                                  
Edit > Selection Respects Soft Boundaries                                               `Selection Respects Soft Boundaries`                                          
Edit > Select Output of Last Command                                                    `Select Output of Last Command`                                               
Edit > Select Current Command                                                           `Select Current Command`                                                      
Edit > Find > Find…                                                                     `Find.Find...`                                                                
Edit > Find > Find Next                                                                 `Find.Find Next`                                                              
Edit > Find > Find Previous                                                             `Find.Find Previous`                                                          
Edit > Find > Use Selection for Find                                                    `Find.Use Selection for Find`                                                 
Edit > Find > Clear Find                                                                `Find.Clear Find`                                                             
Edit > Find > Find Globally…                                                            `Find.Find Globally...`                                                       
Edit > Find > Select Matches                                                            `Find.ConvertMatchesToSelections`                                             
Edit > Find > Find URLs                                                                 `Find.Find URLs`                                                              
Edit > Find > Find All Smart Selection Matches                                          `Find.Find All Smart Selection  Matches`                                      
Edit > Find > Pick Result to Open                                                       `Find.Pick Result To Open`                                                    
Edit > Find > Filter                                                                    `Find.Filter`                                                                 
Edit > Marks and Annotations > Set Mark                                                 `Marks and Annotations.Set Mark`                                              
Edit > Marks and Annotations > Jump to Mark                                             `Marks and Annotations.Jump to Mark`                                          
Edit > Marks and Annotations > Next Mark                                                `Marks and Annotations.Next Mark`                                             
Edit > Marks and Annotations > Previous Mark                                            `Marks and Annotations.Previous Mark`                                         
Edit > Marks and Annotations > Add Annotation at Cursor                                 `Marks and Annotations.Add Annotation at Cursor`                              
Edit > Marks and Annotations > Annotate Selection                                       `Marks and Annotations.Annotate Selection`                                    
Edit > Marks and Annotations > Next Annotation                                          `Marks and Annotations.Next  Annotation`                                      
Edit > Marks and Annotations > Previous Annotation                                      `Marks and Annotations.Previous  Annotation`                                  
Edit > Marks and Annotations > Alerts > Alert on Next Mark                              `Marks and Annotations.Alerts.Alert on Next Mark`                             
Edit > Marks and Annotations > Alerts > Alert on Marks in Offscreen Sessions            `Marks and Alerts.Alerts.Alert on Marks in Offscreen Sessions`                
Edit > Marks and Annotations > Alerts > Show Modal Alert Box                            `Marks and Annotations.Alerts.Show Modal Alert Box`                           
Edit > Marks and Annotations > Alerts > Post Notification                               `Marks and Annotations.Alerts.Post Notification`                              
Edit > Marks and Annotations > Alerts > Play a Sound                                    `Marks and Notes.Alerts.Play a Sound`                                         
Edit > Set Named Mark                                                                   `Set Named Mark`                                                              
Edit > Fold Selected Lines                                                              `Fold Selected Lines`                                                         
Edit > Fold All                                                                         `Fold All Above Cursor`                                                       
Edit > Clear Buffer                                                                     `Clear Buffer`                                                                
Edit > Clear Instant Replay                                                             `Clear Instant Replay`                                                        
Edit > Clear Scrollback Buffer                                                          `Clear Scrollback Buffer`                                                     
Edit > Clear to Start of Selection                                                      `Clear to Start of Selection`                                                 
Edit > Clear to Last Mark                                                               `Clear to Last Mark`                                                          
View > Show Tabs in Fullscreen                                                          `Show Tabs in Fullscreen`                                                     
View > Toggle Full Screen                                                               `Toggle Full Screen`                                                          
View > Toolbelt > Show Toolbelt                                                         `Show Toolbelt`                                                               
View > Toolbelt > Set Default Width                                                     `Set Default Width`                                                           
View > Use Transparency                                                                 `Use Transparency`                                                            
View > Disable Transparency for Active Window                                           `Disable Transparency for Active Window`                                      
View > Zoom In on Selection                                                             `Zoom In on Selection`                                                        
View > Zoom Out                                                                         `Zoom Out`                                                                    
View > Find Cursor                                                                      `Find Cursor`                                                                 
View > Show Annotations                                                                 `Show Annotations`                                                            
View > Show Clippings                                                                   `Show Clippings`                                                              
View > Show Inline Chat                                                                 `Show Inline Chat`                                                            
View > Show Composer                                                                    `Composer`                                                                    
View > Show Cursor Guide                                                                `Show Cursor Guide`                                                           
View > Show Session Note                                                                `Edit Session Note`                                                           
View > Show Timestamps                                                                  `Show Timestamps`                                                             
View > Auto Command Completion                                                          `Auto Command Completion`                                                     
View > Auto Composer                                                                    `Auto Composer`                                                               
View > Open Quickly                                                                     `Open Quickly`                                                                
View > Maximize Active Pane                                                             `Maximize Active Pane`                                                        
View > Make Text Bigger                                                                 `Make Text Bigger`                                                            
View > Make Text Normal Size                                                            `Make Text Normal Size`                                                       
View > Restore Text and Session Size                                                    `Restore Text and Session Size`                                               
View > Make Text Smaller                                                                `Make Text Smaller`                                                           
View > Size Changes Update Profile                                                      `Size Changes Update Profile`                                                 
View > Start Instant Replay                                                             `Start Instant Replay`                                                        
Session > Edit Session…                                                                 `Edit Session…`                                                               
Session > Run Coprocess…                                                                `Run Coprocess…`                                                              
Session > Stop Coprocess                                                                `Stop Coprocess`                                                              
Session > Restart Session                                                               `Restart Session`                                                             
Session > Duplicate Session                                                             `Duplicate Session`                                                           
Session > Open Autocomplete…                                                            `Open Autocomplete…`                                                          
Session > Open Command History…                                                         `Open Command History…`                                                       
Session > Open Recent Directories…                                                      `Open Recent Directories…`                                                    
Session > Open Paste History…                                                           `Open Paste History…`                                                         
Session > Open AI Chat…                                                                 `Open AI Chat`                                                                
Session > Triggers > Add Trigger…                                                       `Add Trigger`                                                                 
Session > Triggers > Edit Triggers                                                      `Edit Triggers`                                                               
Session > Triggers > Enable Triggers in Interactive Apps                                `Enable Triggers in Interactive Apps`                                         
Session > Triggers > Enable All                                                         `Triggers.Enable All`                                                         
Session > Triggers > Disable All                                                        `Triggers.Disable All`                                                        
Session > Reset                                                                         `Reset`                                                                       
Session > Reset Character Set                                                           `Reset Character Set`                                                         
Session > Move Session > Move Session to Window                                         `Move Session to Window`                                                      
Session > Move Session > Move Session to Tab                                            `Move Session to Tab`                                                         
Session > Move Session > Move Session to Split Pane                                     `Move Session to Split Pane`                                                  
Session > Log > Log to File                                                             `Log.Toggle`                                                                  
Session > Log > Import Recording                                                        `Log.ImportRecording`                                                         
Session > Log > Export Recording                                                        `Log.ExportRecording`                                                         
Session > Make Screenshot…                                                              `Make Screenshot`                                                             
Session > Terminal State > Alternate Screen                                             `Alternate Screen`                                                            
Session > Terminal State > Focus Reporting                                              `Focus Reporting`                                                             
Session > Terminal State > Mouse Reporting                                              `Mouse Reporting`                                                             
Session > Terminal State > Paste Bracketing                                             `Paste Bracketing`                                                            
Session > Terminal State > Application Cursor                                           `Application Cursor`                                                          
Session > Terminal State > Application Keypad                                           `Application Keypad`                                                          
Session > Terminal State > Standard Key Reporting Mode                                  `Terminal State.Standard Key Reporting`                                       
Session > Terminal State > modifyOtherKeys Mode 1                                       `Terminal State.Report Modifiers like xterm 1`                                
Session > Terminal State > modifyOtherKeys Mode 2                                       `Terminal State.Report Modifiers like xterm 2`                                
Session > Terminal State > CSI u Mode                                                   `Terminal State.Report Modifiers with CSI u`                                  
Session > Terminal State > Raw Key Reporting Mode                                       `Terminal State.Raw Key Reporting`                                            
Session > Terminal State > Disambiguate Escape                                          `Terminal State.Disambiguate Escape`                                          
Session > Terminal State > Report All Event Types                                       `Terminal State.Report All Event Types`                                       
Session > Terminal State > Report Alternate Keys                                        `Terminal State.Report Alternate Keys`                                        
Session > Terminal State > Report All Keys as Escape Codes                              `Terminal State.Report All Keys as Escape Codes`                              
Session > Terminal State > Report Associated Text                                       `Terminal State.Report Associated Text`                                       
Session > Terminal State > Literal Controls                                             `Terminal State.Literal Mode`                                                 
Session > Terminal State > Emulation Level > VT100                                      `Terminal State.Emulation Level.VT100`                                        
Session > Terminal State > Emulation Level > VT200                                      `Terminal State.Emulation Level.VT200`                                        
Session > Terminal State > Emulation Level > VT300                                      `Terminal State.Emulation Level.VT300`                                        
Session > Terminal State > Emulation Level > VT400                                      `Terminal State.Emulation Level.VT400`                                        
Session > Terminal State > Emulation Level > VT500                                      `Terminal State.Emulation Level.VT500`                                        
Session > Terminal State > Reset                                                        `Reset Terminal State`                                                        
Session > Bury Session                                                                  `Bury Session`                                                                
Scripts > Manage > New Python Script                                                    `New Python Script`                                                           
Scripts > Manage > Open Python REPL                                                     `Open Interactive Window`                                                     
Scripts > Manage > Manage Dependencies…                                                 `Manage Dependencies`                                                         
Scripts > Manage > Install Python Runtime                                               `Install Python Runtime`                                                      
Scripts > Manage > Install Already-Downloaded Python Runtime                            `Install Already-Downloaded Python Runtime`                                   
Scripts > Manage > Reveal Scripts in Finder                                             `Reveal in Finder`                                                            
Scripts > Manage > Import…                                                              `Import Script`                                                               
Scripts > Manage > Export…                                                              `Export Script`                                                               
Scripts > Manage > Console                                                              `Script Console`                                                              
Profiles > Open Profiles…                                                               `Open Profiles…`                                                              
Profiles > Press Option for New Window                                                  `Press Option for New Window`                                                 
Profiles > Open In New Window                                                           `Open In New Window`                                                          
Window > Minimize                                                                       `Minimize`                                                                    
Window > Zoom                                                                           `Zoom`                                                                        
Window > Arrangements > Save Window Arrangement                                         `Save Window Arrangement`                                                     
Window > Arrangements > Save Current Window as Arrangement                              `Save Current Window as Arrangement`                                          
Window > Arrangements > Load Arrangement from File…                                     `Load Arrangement from File…`                                                 
Window > Arrangements > Change Profile in Arrangement…                                  `Change Profile in Arrangement…`                                              
Window > Edit Window Title                                                              `Edit Window Title`                                                           
Window > Tab > Edit Tab Title                                                           `Edit Tab Title`                                                              
Window > Tab > Select Tab > Select Next Tab                                             `Select Next Tab`                                                             
Window > Tab > Select Tab > Select Previous Tab                                         `Select Previous Tab`                                                         
Window > Tab > Select Tab > Select Next Tab Group                                       `Select Next Tab Group`                                                       
Window > Tab > Select Tab > Select Previous Tab Group                                   `Select Previous Tab Group`                                                   
Window > Tab > Move Tab > Move Tab to New Window                                        `Move Tab to New Window`                                                      
Window > Tab > Move Tab > Move Tab Left                                                 `Move Tab Left`                                                               
Window > Tab > Move Tab > Move Tab Right                                                `Move Tab Right`                                                              
Window > Tab > Add Tab to New Group…                                                    `Add Tab to New Group`                                                        
Window > Tab > Remove Tab from Group                                                    `Remove Tab from Group`                                                       
Window > Split Pane > Select Split Pane > Select Pane Above                             `Select Split Pane.Select Pane Above`                                         
Window > Split Pane > Select Split Pane > Select Pane Below                             `Select Split Pane.Select Pane Below`                                         
Window > Split Pane > Select Split Pane > Select Pane Left                              `Select Split Pane.Select Pane Left`                                          
Window > Split Pane > Select Split Pane > Select Pane Right                             `Select Split Pane.Select Pane Right`                                         
Window > Split Pane > Select Split Pane > Next Pane                                     `Select Split Pane.Next Pane`                                                 
Window > Split Pane > Select Split Pane > Previous Pane                                 `Select Split Pane.Previous Pane`                                             
Window > Split Pane > Select Split Pane > Previous Peer                                 `Select Split Pane.Previous Peer`                                             
Window > Split Pane > Select Split Pane > Next Peer                                     `Select Split Pane.Next Peer`                                                 
Window > Split Pane > Resize Split Pane > Move Divider Up                               `Resize Split Pane.Move Divider Up`                                           
Window > Split Pane > Resize Split Pane > Move Divider Down                             `Resize Split Pane.Move Divider Down`                                         
Window > Split Pane > Resize Split Pane > Move Divider Left                             `Resize Split Pane.Move Divider Left`                                         
Window > Split Pane > Resize Split Pane > Move Divider Right                            `Resize Split Pane.Move Divider Right`                                        
Window > Split Pane > Lock Split Pane Width                                             `Lock Split Pane Width`                                                       
Window > Window Style > Normal                                                          `Window Style.Normal`                                                         
Window > Window Style > Full Screen                                                     `Window Style.Full Screen`                                                    
Window > Window Style > Maximized                                                       `Window Style.Maximized`                                                      
Window > Window Style > No Title Bar                                                    `Window Style.No Title Bar`                                                   
Window > Window Style > Centered                                                        `Window Style.Centered`                                                       
Window > Window Style > Centered, No Title Bar                                          `Window Style.Centered No Title Bar`                                          
Window > Window Style > Full-Width Bottom of Screen                                     `Window Style.FullWidth Bottom of Screen`                                     
Window > Window Style > Full-Width Top of Screen                                        `Window Style.FullWidth Top of Screen`                                        
Window > Window Style > Full-Height Left of Screen                                      `Window Style..FullHeight Left of Screen`                                     
Window > Window Style > Full-Height Right of Screen                                     `Window Style.FullHeight Right of Screen`                                     
Window > Window Style > Bottom of Screen                                                `Window Style.Bottom of Screen`                                               
Window > Window Style > Top of Screen                                                   `Window Style.Top of Screen`                                                  
Window > Window Style > Left of Screen                                                  `Window Style.Left of Screen`                                                 
Window > Window Style > Right of Screen                                                 `Window Style.Right of Screen`                                                
Window > Lock Size                                                                      `Lock Size`                                                                   
Window > Lock Layout                                                                    `Lock Layout`                                                                 
Window > Lock Location                                                                  `Lock Location`                                                               
Window > Notify on Status Change                                                        `Notify on Status Change`                                                     
Window > Arrange Windows > Merge All Windows                                            `Merge All Windows`                                                           
Window > Arrange Windows > Arrange Windows Horizontally                                 `Arrange Windows Horizontally`                                                
Window > Arrange Windows > Arrange Split Panes Evenly                                   `Arrange Split Panes Evenly`                                                  
Window > Resize Window > Decrease Height                                                `Resize Window.Decrease Height`                                               
Window > Resize Window > Increase Height                                                `Resize Window.Increase Height`                                               
Window > Resize Window > Decrease Width                                                 `Resize Window.Decrease Width`                                                
Window > Resize Window > Increase Width                                                 `Resize Window.Increase Width`                                                
Window > Password Manager                                                               `Password Manager`                                                            
Window > AI Chats                                                                       `AI Chats`                                                                    
Window > Cockpit                                                                        `Cockpit`                                                                     
Window > Pin Hotkey Window                                                              `Pin Hotkey Window`                                                           
Window > Bring All To Front                                                             `Bring All To Front`                                                          
Help > iTerm2 Help                                                                      `iTerm2 Help`                                                                 
Help > Copy Mode Shortcuts                                                              `Copy Mode Shortcuts`                                                         
Help > Open Source Licenses                                                             `Open Source Licenses`                                                        
Help > GPU Renderer Availability                                                        `GPU Renderer Availability`                                                   
======================================================================================= ==============================================================================

----

Indices and tables
==================

* :ref:`genindex`
* :ref:`search`

---

### Api Library Python Iterm2 Docs Preferences (`api/library/python/iterm2/docs/preferences.rst`)

Preferences
-----------
.. automodule:: iterm2.preferences

.. autoclass:: iterm2.PreferenceKey
   :undoc-members:
   :members:

.. autofunction:: iterm2.async_get_preference
.. autofunction:: iterm2.async_set_preference

----

Indices and tables
==================

* :ref:`genindex`
* :ref:`search`

---

### Api Library Python Iterm2 Docs Profile (`api/library/python/iterm2/docs/profile.rst`)

Profile
-------
.. automodule:: iterm2.profile
.. autoclass:: iterm2.Profile
   :members: advanced_working_directory_pane_directory, advanced_working_directory_pane_setting, advanced_working_directory_tab_directory, advanced_working_directory_tab_setting, advanced_working_directory_window_directory, advanced_working_directory_window_setting, all_properties, allow_title_reporting, allow_title_setting, ambiguous_double_width, ansi_0_color, ansi_10_color, ansi_11_color, ansi_12_color, ansi_13_color, ansi_14_color, ansi_15_color, ansi_1_color, ansi_2_color, ansi_3_color, ansi_4_color, ansi_5_color, ansi_6_color, ansi_7_color, ansi_8_color, ansi_9_color, answerback_string, application_keypad_allowed, ascii_anti_aliased, ascii_ligatures, async_get, async_get_default, async_make_default, async_set_advanced_working_directory_pane_directory, async_set_advanced_working_directory_pane_setting, async_set_advanced_working_directory_tab_directory, async_set_advanced_working_directory_tab_setting, async_set_advanced_working_directory_window_directory, async_set_advanced_working_directory_window_setting, async_set_allow_title_reporting, async_set_allow_title_setting, async_set_ambiguous_double_width, async_set_ansi_0_color, async_set_ansi_10_color, async_set_ansi_11_color, async_set_ansi_12_color, async_set_ansi_13_color, async_set_ansi_14_color, async_set_ansi_15_color, async_set_ansi_1_color, async_set_ansi_2_color, async_set_ansi_3_color, async_set_ansi_4_color, async_set_ansi_5_color, async_set_ansi_6_color, async_set_ansi_7_color, async_set_ansi_8_color, async_set_ansi_9_color, async_set_answerback_string, async_set_application_keypad_allowed, async_set_ascii_anti_aliased, async_set_ascii_ligatures, async_set_automatic_profile_switching_rules, async_set_background_color, async_set_background_image_location, async_set_background_image_mode, async_set_badge_color, async_set_badge_font, async_set_badge_max_height, async_set_badge_max_width, async_set_badge_right_margin, async_set_badge_text, async_set_badge_top_margin, async_set_blend, async_set_blink_allowed, async_set_blinking_cursor, async_set_blur, async_set_blur_radius, async_set_bm_growl, async_set_bold_color, async_set_character_encoding, async_set_close_sessions_on_end, async_set_color_preset, async_set_command, async_set_cursor_boost, async_set_cursor_color, async_set_cursor_guide_color, async_set_cursor_text_color, async_set_cursor_type, async_set_custom_directory, async_set_custom_icon_path, async_set_custom_window_title, async_set_disable_printing, async_set_disable_smcup_rmcup, async_set_disable_window_resizing, async_set_flashing_bell, async_set_foreground_color, async_set_horizontal_spacing, async_set_icon_mode, async_set_idle_code, async_set_idle_period, async_set_initial_directory_mode, async_set_key_mappings, async_set_left_option_key_changeable, async_set_left_option_key_sends, async_set_link_color, async_set_minimum_contrast, async_set_mouse_reporting, async_set_mouse_reporting_allow_mouse_wheel, async_set_name, async_set_non_ascii_anti_aliased, async_set_non_ascii_font, async_set_non_ascii_ligatures, async_set_normal_font, async_set_only_the_default_bg_color_uses_transparency, async_set_place_prompt_at_first_column, async_set_prompt_before_closing, async_set_reduce_flicker, async_set_right_option_key_changeable, async_set_right_option_key_sends, async_set_scrollback_in_alternate_screen, async_set_scrollback_lines, async_set_scrollback_with_status_bar, async_set_selected_text_color, async_set_selection_color, async_set_semantic_history, async_set_send_bell_alert, async_set_send_code_when_idle, async_set_send_idle_alert, async_set_send_new_output_alert, async_set_send_session_ended_alert, async_set_send_terminal_generated_alerts, async_set_session_close_undo_timeout, async_set_show_mark_indicators, async_set_silence_bell, async_set_smart_cursor_color, async_set_smart_selection_rules, async_set_status_bar_enabled, async_set_sync_title, async_set_tab_color, async_set_thin_strokes, async_set_title_components, async_set_title_function, async_set_touchbar_mappings, async_set_transparency, async_set_triggers, async_set_triggers_use_interpolated_strings, async_set_underline_color, async_set_unicode_normalization, async_set_unicode_version, async_set_unlimited_scrollback, async_set_use_bold_font, async_set_use_bright_bold, async_set_use_built_in_powerline_glyphs, async_set_use_csi_u, async_set_use_cursor_guide, async_set_use_custom_command, async_set_use_custom_window_title, async_set_use_italic_font, async_set_use_non_ascii_font, async_set_use_tab_color, async_set_use_transparency_initially, async_set_use_underline_color, async_set_vertical_spacing, async_set_visual_bell, automatic_profile_switching_rules, background_color, background_image_location, background_image_mode, badge_color, badge_font, badge_max_height, badge_max_width, badge_right_margin, badge_text, badge_top_margin, blend, blink_allowed, blinking_cursor, blur, blur_radius, bm_growl, bold_color, character_encoding, close_sessions_on_end, command, cursor_boost, cursor_color, cursor_guide_color, cursor_text_color, cursor_type, custom_directory, custom_icon_path, custom_window_title, disable_printing, disable_smcup_rmcup, disable_window_resizing, dynamic_profile_file_name, dynamic_profile_parent_name, flashing_bell, foreground_color, guid, horizontal_spacing, icon_mode, idle_code, idle_period, initial_directory_mode, key_mappings, left_option_key_changeable, left_option_key_sends, link_color, local_write_only_copy, minimum_contrast, mouse_reporting, mouse_reporting_allow_mouse_wheel, name, non_ascii_anti_aliased, non_ascii_font, non_ascii_ligatures, normal_font, only_the_default_bg_color_uses_transparency, original_guid, place_prompt_at_first_column, prompt_before_closing, reduce_flicker, right_option_key_changeable, right_option_key_sends, scrollback_in_alternate_screen, scrollback_lines, scrollback_with_status_bar, selected_text_color, selection_color, semantic_history, send_bell_alert, send_code_when_idle, send_idle_alert, send_new_output_alert, send_session_ended_alert, send_terminal_generated_alerts, session_close_undo_timeout, show_mark_indicators, silence_bell, smart_cursor_color, smart_selection_rules, status_bar_enabled, sync_title, tab_color, thin_strokes, title_components, title_function, touchbar_mappings, transparency, triggers, triggers_use_interpolated_strings, underline_color, unicode_normalization, unicode_version, unlimited_scrollback, use_bold_font, use_bright_bold, use_built_in_powerline_glyphs, use_csi_u, use_cursor_guide, use_custom_command, use_custom_window_title, use_italic_font, use_non_ascii_font, use_tab_color, use_transparency_initially, use_underline_color, vertical_spacing, visual_bell
.. autoclass:: iterm2.LocalWriteOnlyProfile
   :members: set_advanced_working_directory_pane_directory, set_advanced_working_directory_pane_setting, set_advanced_working_directory_tab_directory, set_advanced_working_directory_tab_setting, set_advanced_working_directory_window_directory, set_advanced_working_directory_window_setting, set_allow_title_reporting, set_allow_title_setting, set_ambiguous_double_width, set_ansi_0_color, set_ansi_10_color, set_ansi_11_color, set_ansi_12_color, set_ansi_13_color, set_ansi_14_color, set_ansi_15_color, set_ansi_1_color, set_ansi_2_color, set_ansi_3_color, set_ansi_4_color, set_ansi_5_color, set_ansi_6_color, set_ansi_7_color, set_ansi_8_color, set_ansi_9_color, set_answerback_string, set_application_keypad_allowed, set_ascii_anti_aliased, set_ascii_ligatures, set_automatic_profile_switching_rules, set_background_color, set_background_image_location, set_background_image_mode, set_badge_color, set_badge_font, set_badge_max_height, set_badge_max_width, set_badge_right_margin, set_badge_text, set_badge_top_margin, set_blend, set_blink_allowed, set_blinking_cursor, set_blur, set_blur_radius, set_bm_growl, set_bold_color, set_character_encoding, set_close_sessions_on_end, set_command, set_cursor_boost, set_cursor_color, set_cursor_guide_color, set_cursor_text_color, set_cursor_type, set_custom_directory, set_custom_icon_path, set_custom_window_title, set_disable_printing, set_disable_smcup_rmcup, set_disable_window_resizing, set_flashing_bell, set_foreground_color, set_horizontal_spacing, set_icon_mode, set_idle_code, set_idle_period, set_initial_directory_mode, set_key_mappings, set_left_option_key_changeable, set_left_option_key_sends, set_link_color, set_minimum_contrast, set_mouse_reporting, set_mouse_reporting_allow_mouse_wheel, set_name, set_non_ascii_anti_aliased, set_non_ascii_font, set_non_ascii_ligatures, set_normal_font, set_only_the_default_bg_color_uses_transparency, set_place_prompt_at_first_column, set_prompt_before_closing, set_reduce_flicker, set_right_option_key_changeable, set_right_option_key_sends, set_scrollback_in_alternate_screen, set_scrollback_lines, set_scrollback_with_status_bar, set_selected_text_color, set_selection_color, set_semantic_history, set_send_bell_alert, set_send_code_when_idle, set_send_idle_alert, set_send_new_output_alert, set_send_session_ended_alert, set_send_terminal_generated_alerts, set_session_close_undo_timeout, set_show_mark_indicators, set_silence_bell, set_smart_cursor_color, set_smart_selection_rules, set_status_bar_enabled, set_sync_title, set_tab_color, set_thin_strokes, set_title_components, set_title_function, set_touchbar_mappings, set_transparency, set_triggers, set_triggers_use_interpolated_strings, set_underline_color, set_unicode_normalization, set_unicode_version, set_unlimited_scrollback, set_use_bold_font, set_use_bright_bold, set_use_built_in_powerline_glyphs, set_use_csi_u, set_use_cursor_guide, set_use_custom_command, set_use_custom_window_title, set_use_italic_font, set_use_non_ascii_font, set_use_tab_color, set_use_transparency_initially, set_use_underline_color, set_vertical_spacing, set_visual_bell

.. autoclass:: iterm2.PartialProfile
   :members: async_query, async_get_full_profile, async_make_default

.. autoclass:: iterm2.BackgroundImageMode
   :undoc-members:
   :members:

.. autoclass:: iterm2.CursorType
   :members:
.. autoclass:: iterm2.ThinStrokes
   :members:
.. autoclass:: iterm2.UnicodeNormalization
   :members:
.. autoclass:: iterm2.CharacterEncoding
   :members:
.. autoclass:: iterm2.OptionKeySends
   :members:
.. autoclass:: iterm2.InitialWorkingDirectory
   :members:


----

Indices and tables
==================

* :ref:`genindex`
* :ref:`search`

---

### Api Library Python Iterm2 Docs Prompt (`api/library/python/iterm2/docs/prompt.rst`)

Prompt
------
.. automodule:: iterm2.prompt
.. autoclass:: iterm2.PromptState
   :undoc-members:
   :members:
.. autoclass:: iterm2.Prompt
   :members: prompt_range, command_range, output_range, working_directory, command
.. autoclass:: iterm2.PromptMonitor
   :members: async_get
.. autoclass:: iterm2.PromptMonitor.Mode
   :undoc-members:
   :members:

.. autofunction:: iterm2.async_get_last_prompt
.. autofunction:: iterm2.async_get_prompt_by_id


----

Indices and tables
==================

* :ref:`genindex`
* :ref:`search`

---

### Api Library Python Iterm2 Docs Registration (`api/library/python/iterm2/docs/registration.rst`)

Registration
------------
.. automodule:: iterm2.registration
.. autofunction:: RPC
.. autofunction:: TitleProviderRPC
.. autofunction:: StatusBarRPC
.. autofunction:: ContextMenuProviderRPC
.. autoclass:: iterm2.Reference

----

Indices and tables
==================

* :ref:`genindex`
* :ref:`search`

---

### Api Library Python Iterm2 Docs Screen (`api/library/python/iterm2/docs/screen.rst`)

Screen
------
.. automodule:: iterm2.screen
.. autoclass:: iterm2.ScreenStreamer
   :members: async_get
.. autoclass:: iterm2.ScreenContents
   :members: number_of_lines, line, cursor_coord, number_of_lines_above_screen
.. autoclass:: iterm2.LineContents
   :members: string, string_at, hard_eol

----

Indices and tables
==================

* :ref:`genindex`
* :ref:`search`

---

### Api Library Python Iterm2 Docs Selection (`api/library/python/iterm2/docs/selection.rst`)

Selection
---------
.. automodule:: iterm2.selection
.. autoclass:: iterm2.SelectionMode
   :undoc-members:
   :members:

.. autoclass:: iterm2.SubSelection
   :members: windowed_coord_range, windowedCoordRange, mode, async_get_string
.. autoclass:: iterm2.Selection
   :members: subSelections, async_get_string

----

Indices and tables
==================

* :ref:`genindex`
* :ref:`search`

---

### Api Library Python Iterm2 Docs Session (`api/library/python/iterm2/docs/session.rst`)

Session
-------
.. automodule:: iterm2.session
.. autoclass:: iterm2.Session
   :members: active_proxy, all_proxy, pretty_str, session_id, get_screen_streamer, async_send_text, async_split_pane, async_get_profile, async_set_profile, async_inject, async_activate, async_set_variable, async_get_variable, async_set_grid_size, async_set_buried, async_get_line_info, async_get_selection, async_get_selection_text, async_set_selection, async_close, async_set_profile_properties, async_get_screen_contents, async_invoke_function, grid_size, preferred_size, async_set_name, async_run_tmux_command, async_get_contents, tab, window, async_restart, async_get_coprocess, async_stop_coprocess, async_run_coprocess, async_add_annotation, async_get_session_note, async_set_session_note

.. autoclass:: iterm2.session.InvalidSessionId
.. autoclass:: iterm2.session.SplitPaneException
.. autoclass:: iterm2.session.SessionLineInfo
   :members:

.. autoclass:: iterm2.session.SessionNote
   :members:

.. autoclass:: iterm2.session.Splitter
   :members: children, sessions, pretty_str, vertical



----

Indices and tables
==================

* :ref:`genindex`
* :ref:`search`

---

### Api Library Python Iterm2 Docs Statusbar (`api/library/python/iterm2/docs/statusbar.rst`)

Status Bar
----------
.. automodule:: iterm2.statusbar
.. autoclass:: iterm2.StatusBarComponent
   :members: async_open_popover, async_register
.. autoclass:: iterm2.CheckboxKnob
.. autoclass:: iterm2.StringKnob
.. autoclass:: iterm2.PositiveFloatingPointKnob
.. autoclass:: iterm2.ColorKnob
.. autoclass:: iterm2.StatusBarComponent.Icon


----

Indices and tables
==================

* :ref:`genindex`
* :ref:`search`

---

### Api Library Python Iterm2 Docs Tab (`api/library/python/iterm2/docs/tab.rst`)

Tab
---
.. automodule:: iterm2.tab
.. autoclass:: iterm2.Tab
   :members: active_session_id, async_activate, async_add_to_tab_group, async_close, async_get_variable, async_invoke_function, async_move_to_window, async_remove_from_tab_group, async_select, async_select_pane_in_direction, async_set_title, async_set_variable, async_update_layout, current_session, pretty_str, root, sessions, tab_group, tab_id, tmux_connection_id, tmux_window_id, window



----

Indices and tables
==================

* :ref:`genindex`
* :ref:`search`

---

### Api Library Python Iterm2 Docs Tabgroup (`api/library/python/iterm2/docs/tabgroup.rst`)

TabGroup
--------
.. automodule:: iterm2.tabgroup
.. autoclass:: iterm2.TabGroup
   :members: group_id, name, color, collapsed, async_set_name, async_set_color, async_set_collapsed



----

Indices and tables
==================

* :ref:`genindex`
* :ref:`search`

---

### Api Library Python Iterm2 Docs Tmux (`api/library/python/iterm2/docs/tmux.rst`)

Tmux
----
.. automodule:: iterm2.tmux
.. autofunction:: iterm2.async_get_tmux_connections
.. autofunction:: iterm2.async_get_tmux_connection_by_connection_id
.. autoclass:: iterm2.TmuxException
.. autoclass:: iterm2.TmuxConnection
   :members: connection_id, owning_session, async_send_command, async_set_tmux_window_visible, async_create_window

----

Indices and tables
==================

* :ref:`genindex`
* :ref:`search`

---

### Api Library Python Iterm2 Docs Tool (`api/library/python/iterm2/docs/tool.rst`)

Tool
----
.. automodule:: iterm2.tool
.. autofunction:: async_register_web_view_tool



----

Indices and tables
==================

* :ref:`genindex`
* :ref:`search`

---

### Api Library Python Iterm2 Docs Transaction (`api/library/python/iterm2/docs/transaction.rst`)

Transaction
-----------
.. automodule:: iterm2.transaction
.. autoclass:: iterm2.Transaction



----

Indices and tables
==================

* :ref:`genindex`
* :ref:`search`

---

### Api Library Python Iterm2 Docs Util (`api/library/python/iterm2/docs/util.rst`)

Utilities
---------
.. automodule:: iterm2.util
   :members: frame_str, size_str

----

.. autoclass:: iterm2.util.Size
   :members: width, height, json

----

.. autoclass:: iterm2.util.Point
   :members: x, y, json

----

.. autoclass:: iterm2.util.Frame
   :members: origin, size, json

----

.. autoclass:: iterm2.util.CoordRange
   :members: start, end

----

.. autoclass:: iterm2.util.Range
   :members: location, length

----

.. autoclass:: iterm2.util.WindowedCoordRange
   :members: coordRange, columnRange


----

Indices and tables
==================

* :ref:`genindex`
* :ref:`search`

---

### Api Library Python Iterm2 Docs Variables (`api/library/python/iterm2/docs/variables.rst`)

Variables
---------
.. automodule:: iterm2.variables
.. autoclass:: iterm2.VariableMonitor
   :members: async_get
.. autoclass:: iterm2.VariableScopes
   :undoc-members:
   :members:


----

Indices and tables
==================

* :ref:`genindex`
* :ref:`search`

---

### Api Library Python Iterm2 Docs Window (`api/library/python/iterm2/docs/window.rst`)

Window
------
.. autoclass:: iterm2.Window
   :members: pretty_str, window_id, tabs, tab_groups, async_create, async_create_tab, async_create_tab_group, async_get_frame, async_set_frame, async_get_fullscreen, async_set_fullscreen, async_activate, async_save_window_as_arrangement, async_restore_window_arrangement , current_tab, async_set_tabs, async_create_tmux_tab, async_close, async_invoke_function, async_set_variable
.. autoclass:: iterm2.CreateTabException
.. autoclass:: iterm2.SetPropertyException
.. autoclass:: iterm2.GetPropertyException
.. autoclass:: iterm2.SavedArrangementException


----

Indices and tables
==================

* :ref:`genindex`
* :ref:`search`

---

### Api Library Python Iterm2 Docs Examples App Tab Color (`api/library/python/iterm2/docs/examples/app_tab_color.rst`)

:orphan:

.. _app_tab_color_example:

Set Tab Color from Current App
==============================

This script sets the tab color based on the current app.

.. code-block:: python

    #!/usr/bin/env python3

    import iterm2
    import os
    import random

    colors = { "vi": (255, 255, 255), "emacs": (255, 0, 0) }

    async def SetTabColor(connection, session, color):
	change = iterm2.LocalWriteOnlyProfile()
	if color:
	    change.set_tab_color(color)
	    change.set_use_tab_color(True)
	else:
	    change.set_use_tab_color(False)
	await session.async_set_profile_properties(change)

    async def UpdateTabColor(connection, session, command):
	try:
	    parts = command.split(" ")
	    command = os.path.basename(os.path.normpath(parts[0]))
	    r,g,b = colors[command]
	    color = iterm2.Color(r, g, b)
	    await SetTabColor(connection, session, color)
	except Exception as e:
	    print(e)

    async def main(connection):
	app = await iterm2.async_get_app(connection)

	async def monitor(session_id):
	    """Run for each session, including existing sessions. Watches for
	    changes to the running commands."""
	    session = app.get_session_by_id(session_id)
	    if not session:
		return
	    alert_task = None
	    modes = [iterm2.PromptMonitor.Mode.COMMAND_END,
		     iterm2.PromptMonitor.Mode.COMMAND_START]
	    async with iterm2.PromptMonitor(
		    connection, session_id, modes=modes) as mon:
		while True:
		    mode, _ = await mon.async_get()
		    if mode == iterm2.PromptMonitor.Mode.COMMAND_START:
			prompt = await iterm2.async_get_last_prompt(connection, session_id)
			await UpdateTabColor(connection, session, prompt.command)
		    else:
			await SetTabColor(connection, session, None)

	await iterm2.EachSessionOnceMonitor.async_foreach_session_create_task(
		app, monitor)

    iterm2.run_forever(main)

:Download:`Download<app_tab_color.its>`

---

### Api Library Python Iterm2 Docs Examples Apply Layout (`api/library/python/iterm2/docs/examples/apply_layout.rst`)

:orphan:

.. _apply_layout_example:

Move Sessions Between Tabs, Windows, and Split Panes
====================================================

This script registers three functions that demonstrate
:meth:`iterm2.App.async_apply_layout` — a single API call that reshapes one
or more tabs atomically. You can bind each function to a keystroke in
**Prefs > Keys** by selecting *Invoke Script Function* and using one of:

* ``swap_first_two_panes()``
* ``move_active_pane_to_next_tab()``
* ``move_active_pane_to_other_window()``

The shared idea: build a *spec* (a plain Python dict) describing the
tabs you want to reshape and any sessions/tabs/windows you want to
close, then hand it to ``app.async_apply_layout(spec)``. The whole
spec is validated up front and applied as a single transaction.

Spec basics
-----------

A spec has up to four optional fields:

.. code-block:: python

    {
        "tabs":           [{"tab_id": "...", "root": <node>}, ...],
        "close_sessions": ["<session-guid>", ...],
        "close_tabs":     ["<tab-id>", ...],
        "close_windows":  ["<window-guid>", ...],
    }

A ``<node>`` is either a leaf referring to a live session:

.. code-block:: python

    {"session_id": "<session-guid>"}

…or a splitter with at least two children:

.. code-block:: python

    {"vertical": True, "children": [<node>, <node>, ...]}

Sessions, tabs, and windows are referred to by the same identifiers
the rest of the Python API uses (``session.session_id``,
``tab.tab_id``, ``window.window_id``).

Example script
--------------

.. code-block:: python

    #!/usr/bin/env python3.7
    """
    Demo: move sessions between tabs, windows, and split panes via
    App.async_apply_layout.

    Registers three RPCs:
      swap_first_two_panes()
      move_active_pane_to_next_tab()
      move_active_pane_to_other_window()

    Bind any of these to a keystroke in Prefs > Keys via "Invoke Script
    Function".
    """

    import iterm2

    def leaf(session):
        """Build a leaf node for a session."""
        return {"session_id": session.session_id}

    def vrow(sessions):
        """Build a vertical-divider row of one or more sessions.

        A single session is returned as a leaf; two or more become a
        splitter. apply_layout requires splitters to have at least two
        children.
        """
        if len(sessions) == 1:
            return leaf(sessions[0])
        return {"vertical": True,
                "children": [leaf(s) for s in sessions]}

    async def main(connection):
        app = await iterm2.async_get_app(connection)

        @iterm2.RPC
        async def swap_first_two_panes():
            """Swap the first two panes of the current tab."""
            tab = app.current_terminal_window.current_tab
            if len(tab.sessions) < 2:
                return
            a, b = tab.sessions[0], tab.sessions[1]
            spec = {
                "tabs": [{
                    "tab_id": tab.tab_id,
                    "root": {
                        "vertical": True,
                        "children": [leaf(b), leaf(a)] +
                                    [leaf(s) for s in tab.sessions[2:]],
                    },
                }],
            }
            await app.async_apply_layout(spec)
        await swap_first_two_panes.async_register(connection)

        @iterm2.RPC
        async def move_active_pane_to_next_tab():
            """Move the active pane from the current tab into the next
            tab in the same window."""
            window = app.current_terminal_window
            tabs = window.tabs
            if len(tabs) < 2:
                return
            src = window.current_tab
            i = next(idx for idx, t in enumerate(tabs)
                     if t.tab_id == src.tab_id)
            dst = tabs[(i + 1) % len(tabs)]
            active = src.current_session
            if active is None:
                return

            remaining = [s for s in src.sessions
                         if s.session_id != active.session_id]
            spec = {"tabs": []}
            if remaining:
                # Source tab keeps the other panes.
                spec["tabs"].append(
                    {"tab_id": src.tab_id, "root": vrow(remaining)})
            # Destination tab gains the moved pane on the right.
            spec["tabs"].append({
                "tab_id": dst.tab_id,
                "root": vrow(list(dst.sessions) + [active]),
            })
            # If the source tab loses every pane, apply_layout will
            # close it for us automatically — no need to list it in
            # close_tabs.
            await app.async_apply_layout(spec)
        await move_active_pane_to_next_tab.async_register(connection)

        @iterm2.RPC
        async def move_active_pane_to_other_window():
            """Move the active pane to the current tab of another
            window. Picks the next window in app.terminal_windows."""
            windows = app.terminal_windows
            if len(windows) < 2:
                return
            src_window = app.current_terminal_window
            i = next(idx for idx, w in enumerate(windows)
                     if w.window_id == src_window.window_id)
            dst_window = windows[(i + 1) % len(windows)]
            src_tab = src_window.current_tab
            dst_tab = dst_window.current_tab
            active = src_tab.current_session
            if active is None:
                return

            remaining = [s for s in src_tab.sessions
                         if s.session_id != active.session_id]
            spec = {"tabs": []}
            if remaining:
                spec["tabs"].append(
                    {"tab_id": src_tab.tab_id, "root": vrow(remaining)})
            spec["tabs"].append({
                "tab_id": dst_tab.tab_id,
                "root": vrow(list(dst_tab.sessions) + [active]),
            })
            await app.async_apply_layout(spec)
        await move_active_pane_to_other_window.async_register(connection)

    iterm2.run_forever(main)

Notes
-----

* **Atomicity.** The whole spec is validated before any mutation
  begins, so a malformed spec leaves the workspace untouched. If a
  per-tab mutation fails partway through (e.g. a tab disappeared
  between validation and execution), already-applied changes stay
  applied — there is no rollback.

* **Auto-close.** When a tab loses its last session as a side effect
  of a session moving away, the tab closes automatically. You don't
  need to list it in ``close_tabs``. Same idea for windows whose last
  tab goes away.

* **Cross-tab and cross-window moves work the same way** — both are
  expressed as a session GUID appearing in a different tab's ``root``
  than where it currently lives. ``apply_layout`` figures out the
  detach/reattach automatically.

* **Splitter rules.** Splitters need at least two children, and you
  can't nest a splitter of the same orientation as its parent (flatten
  it instead). ``apply_layout`` rejects either at validation time.

See :meth:`iterm2.App.async_apply_layout` for the full reference.

:Download:`Download<apply_layout.its>`

---

### Api Library Python Iterm2 Docs Examples Autoalert (`api/library/python/iterm2/docs/examples/autoalert.rst`)

:orphan:

.. _autoalert_example:

Alert on Long-Running Jobs
==========================

This script shows a notification when a job in any session runs for over thirty seconds.

It demonstrates creating a task for every session, monitoring for changes of the currently running command, posting notifications, and some basic asyncio usage.

This script requires that shell integration be installed in order to detect prompts and running commands.

.. code-block:: python

    #!/usr/bin/env python3.7

    import asyncio
    import iterm2

    async def main(connection):
        app = await iterm2.async_get_app(connection)

        async def sleep_then_alert(connection, session):
            """Show a user notification in `session` if this task is allowed to run
            for 30 seconds."""
            await asyncio.sleep(30)
            code = "\u001b]9;Alert\u001b\\"
            await session.async_inject(str.encode(code))


        async def monitor(session_id):
            """Wait for commands to start or stop in this session.

            Start and cancel tasks to wait for long-running tasks."""
            session = app.get_session_by_id(session_id)
            if not session:
                return
            alert_task = None
            modes = [iterm2.PromptMonitor.Mode.PROMPT,
                     iterm2.PromptMonitor.Mode.COMMAND_START,
                     iterm2.PromptMonitor.Mode.COMMAND_END]
            async with iterm2.PromptMonitor(
                    connection, session_id, modes=modes) as mon:
                while True:
                    # This blocks until the status of the session changes. That
                    # happens when a new prompt appears, a command begins running,
                    # or a command finishes.
                    mode, info = await mon.async_get()
                    if alert_task:
                        # Cancel an existing task.
                        alert_task.cancel()
                    if mode == iterm2.PromptMonitor.Mode.COMMAND_START:
                        # A command has started running. Create a task that will
                        # alert if it runs for too long.
                        alert_task = asyncio.create_task(
                                sleep_then_alert(connection, session))

        # Create a task running `monitor` for each session, including those created
        # in the future.
        await iterm2.EachSessionOnceMonitor.async_foreach_session_create_task(
                app, monitor)

    # This instructs the script to run the "main" coroutine and to keep running
    # even after it returns.
    iterm2.run_forever(main)

:Download:`Download<autoalert.its>`

---

### Api Library Python Iterm2 Docs Examples Badgetitle (`api/library/python/iterm2/docs/examples/badgetitle.rst`)

:orphan:

.. _badgetitle_example:

Badge or Window Name in Tab Title
=================================

The script puts the badge name in the tab title. This demonstrates a very simple custom title.

First run the script. Then choose "Badge + Name" in **Prefs > Profiles > General > Title**.

.. code-block:: python

    #!/usr/bin/env python3

    import iterm2

    async def main(connection):
        @iterm2.TitleProviderRPC
        async def badge_title(
            badge=iterm2.Reference("badge?"),
            auto_name=iterm2.Reference("autoName?")):
            if badge and auto_name:
                return auto_name + u" \u2014 " + badge
            elif auto_name:
                return auto_name
            elif badge:
                return badge
            else:
                return "Shell"
        await badge_title.async_register(connection, "Name + Badge", "com.iterm2.example.name-and-badge")

    iterm2.run_forever(main)

:Download:`Download<badgetitle.its>`

Another similar example demonstrates showing the window title in the tab.
Terminal apps may choose to set the window title without setting the tab title,
but some users prefer to see the window title in both places.

.. code-block:: python

    #!/usr/bin/env python3

    import asyncio
    import iterm2

    async def main(connection):
        @iterm2.TitleProviderRPC
        async def window_title_in_tab(
            window_name=iterm2.Reference("terminalWindowName?"),
            auto_name=iterm2.Reference("autoName?")):
            if window_name:
                return window_name
            elif auto_name:
                return auto_name
            else:
                return "Shell"

        await window_title_in_tab.async_register(connection, "Window Name", "com.iterm2.example.window-name")

    iterm2.run_forever(main)

:Download:`Download<windowtitle.its>`

---

### Api Library Python Iterm2 Docs Examples Blending (`api/library/python/iterm2/docs/examples/blending.rst`)

:orphan:

.. _blending_example:

Modify Background Image Blending
--------------------------------

This script demonstrates registering an RPC to adjust the image blending level of the current session. You can bind it to a keystroke by adding a new key binding in **Prefs > Keys**, selecting the action **Invoke Script Function**, and giving it the invocation `blend_more(session_id: id)` or `blend_less(session_id: id)`.

.. code-block:: python

    #!/usr/bin/env python3.7

    import asyncio
    import iterm2

    async def main(connection):
	app = await iterm2.async_get_app(connection)
	async def get_profile_for_session(session_id):
	    session = app.get_session_by_id(session_id)
	    return await session.async_get_profile()

	@iterm2.RPC
	async def blend_more(session_id):
	    profile = await get_profile_for_session(session_id)
	    await profile.async_set_blend(min(1, profile.blend + 0.1))
	await blend_more.async_register(connection)

	@iterm2.RPC
	async def blend_less(session_id):
	    profile = await get_profile_for_session(session_id)
	    await profile.async_set_blend(max(0, profile.blend - 0.1))
	await blend_less.async_register(connection)

    iterm2.run_forever(main)

:Download:`Download<blending.its>`

---

### Api Library Python Iterm2 Docs Examples Broadcast (`api/library/python/iterm2/docs/examples/broadcast.rst`)

:orphan:

.. _broadcast_example:

Asymmetric Broadcast Input
==========================

This script creates four split panes and broadcasts input from the bottom left one to the other three. If a pane other than the bottom-left one has focus, input to it does not get broadcast--it just goes to that pane.

This demonstrates:

* Using a :class:`KeystrokeReader` reader to receive keystrokes
* Using `patterns_to_ignore` to prevent iTerm2 from handling certain keystrokes
* Using :meth:`Session.async_send_text` to send fake keystrokes to a session

.. code-block:: python

    #!/usr/bin/env python3

    import asyncio
    import iterm2

    async def main(connection):
        app = await iterm2.async_get_app(connection)

        # Create four split panes and make the bottom left one active.
        bottomLeft = app.current_terminal_window.current_tab.current_session
        bottomRight = await bottomLeft.async_split_pane(vertical=True)
        topLeft = await bottomLeft.async_split_pane(vertical=False, before=True)
        topRight = await bottomRight.async_split_pane(vertical=False, before=True)
        await bottomLeft.async_activate()
        broadcast_to = [ topLeft, bottomLeft, topRight, bottomRight ]

        async def async_handle_keystroke(keystroke):
            if keystroke.keycode == iterm2.Keycode.ESCAPE:
                # User pressed escape. Terminate script.
                return True
            for session in broadcast_to:
                await session.async_send_text(keystroke.characters)
            return False

        # Construct a pattern that matches all keystrokes except those with a Command modifier.
        # This prevents iTerm2 from handling them when the bottomLeft session has keyboard focus.
        pattern = iterm2.KeystrokePattern()
        pattern.keycodes = [keycode for keycode in iterm2.Keycode]
        pattern.forbidden_modifiers = [iterm2.Modifier.COMMAND]

        future = asyncio.Future()

        # Swallow all keystrokes matching the pattern
        async def filter_all_keystrokes():
          async with iterm2.KeystrokeFilter(connection, [pattern], bottomLeft.session_id) as mon:
              await asyncio.wait([future])

        task = asyncio.create_task(filter_all_keystrokes())


        # This will block until async_handle_keystroke returns True.
        async with iterm2.KeystrokeMonitor(connection, bottomLeft.session_id) as mon:
            done = False
            while not done:
                keystroke = await mon.async_get()
                done = await async_handle_keystroke(keystroke)
                if done:
                    break
            future.set_result(True)

        await task

    iterm2.run_until_complete(main)

:Download:`Download<broadcast.its>`

---

### Api Library Python Iterm2 Docs Examples Ccs (`api/library/python/iterm2/docs/examples/ccs.rst`)

:orphan:

.. _ccs_example:

Custom Escape Sequence
======================

This demonstrates handling a custom escape sequence to perform an action so that
you can identify wihch session used the custom control sequence.

.. code-block:: python

    import asyncio
    import iterm2

    tasks = {}

    async def main(connection):
        app = await iterm2.async_get_app(connection)
        tmtask = asyncio.create_task(monitor_termination(connection))
        async with iterm2.EachSessionOnceMonitor(app) as mon:
            while True:
                session_id = await mon.async_get()
                print(session_id)
                session = app.get_session_by_id(session_id)
                task = asyncio.create_task(monitor_ccs(connection, session, session_id))
                tasks[session_id] = task

    async def monitor_ccs(connection, session, session_id):
        try:
            print(f'Monitor {session_id}')
            async with iterm2.CustomControlSequenceMonitor(
                    connection, "shared-secret", r'^split$', session_id) as mon:
                while True:
                    match = await mon.async_get()
                    print(f'Will split {session_id}')
                    await session.async_split_pane()
        except Exception as e:
            print(f'Exception in {session_id}: {e}')

    async def monitor_termination(connection):
        global tasks
        try:
            async with iterm2.SessionTerminationMonitor(connection) as mon:
                while True:
                    print("Waiting for termination")
                    session_id = await mon.async_get()
                    print("Session {} closed".format(session_id))
                    task = tasks[session_id]
                    del tasks[session_id]
                    print("Cancel task")
                    task.cancel()
                    print("await task")
                    await task
                    print("End of loop")
        except Exception as e:
            print(f'Exception in {session_id}: {e}')

    iterm2.run_forever(main)


:Download:`Download<ccs.its>`

To split the current session while this script is running, invoke this command:

.. code-block:: bash

    printf "\033]1337;Custom=id=%s:%s\a" "shared-secret" "split"

The *shared-secret* string is used to prevent untrusted code from invoking your
function. For example, if you `cat` a text file, it could include escape
sequences, but they won't work unless they contain the proper secret string.

---

### Api Library Python Iterm2 Docs Examples Change Default Profile (`api/library/python/iterm2/docs/examples/change_default_profile.rst`)

:orphan:

.. _change_default_profile_example:

Change Default Profile
======================

This script changes the default profile. It is useful because a profile sourced
from a Dynamic Profile JSON file cannot ordinarily be made the default profile.
Put this in the AutoLaunch folder. It will run after dynamic profiles are
loaded at startup.


.. code-block:: python

    #!/usr/bin/env python3.7

    import iterm2

    async def main(connection):
        all_profiles = await iterm2.PartialProfile.async_query(connection)
        for profile in all_profiles:
            if profile.name == "Your Profile Name Goes Here":
                await profile.async_make_default()
                return

    iterm2.run_until_complete(main)

:Download:`Download<change_default_profile.its>`

---

### Api Library Python Iterm2 Docs Examples Close To The Right (`api/library/python/iterm2/docs/examples/close_to_the_right.rst`)

:orphan:

.. _close_to_the_right_example:

Close Tabs to the Right
=======================

This script defines a function that closes tabs to the right of the current tab in the current window. It demonstrates closing tabs and using `asyncio.gather` to run many async functions at once.

You can bind it to a keystroke in **Prefs > Keys** by selecting the action *Invoke Script Function* and giving it the invocation `close_to_the_right()`.

.. code-block:: python

    #!/usr/bin/env python3

    import asyncio
    import iterm2

    async def main(connection):
        app = await iterm2.async_get_app(connection)

        @iterm2.RPC
        async def close_to_the_right():
            current_tab = app.current_terminal_window.current_tab
            i = app.current_terminal_window.tabs.index(current_tab)
            tabs_to_close=list(app.current_terminal_window.tabs[(i + 1):])
            coros = []
            for tab in tabs_to_close:
                coro = tab.async_close(force=True)
                coros.append(coro)
            await asyncio.gather(*coros)

        await close_to_the_right.async_register(connection)

    iterm2.run_forever(main)

:Download:`Download<close_to_the_right.its>`

---

### Api Library Python Iterm2 Docs Examples Cls (`api/library/python/iterm2/docs/examples/cls.rst`)

:orphan:

.. _cls_example:

Clear All Sessions (all tabs or current tab)
============================================

The script clears the buffer including scrollback history from all sessions in all tabs.

You can bind it to a keystroke in **Prefs > Keys** by selecting the action *Invoke Script Function* and giving it the invocation `clear_all_sessions()`.

Place it in `~/Library/Application Support/iTerm2/Scripts/AutoLaunch/` to start it automatically when iTerm2 is launched.

.. code-block:: python

    #!/usr/bin/env python3

    import asyncio
    import iterm2
    import time

    async def main(connection):
        app = await iterm2.async_get_app(connection)

        @iterm2.RPC
        async def clear_all_sessions():
            code = b'\x1b' + b']1337;ClearScrollback' + b'\x07'
            for window in app.terminal_windows:
                for tab in window.tabs:
                    for session in tab.sessions:
                        await session.async_inject(code)

        await clear_all_sessions.async_register(connection)

    iterm2.run_forever(main)

:Download:`Download<cls.its>`

Here's an a different version that clears panes of the current tab only:

.. code-block:: python

    #!/usr/bin/env python3.7
    import iterm2

    async def main(connection):
	app = await iterm2.async_get_app(connection)
	@iterm2.RPC
	async def clear_current_tab():
	    code = b'\x1b' + b']1337;ClearScrollback' + b'\x07'
	    window = app.current_terminal_window
	    tab = window.current_tab
	    for session in tab.sessions:
		await session.async_inject(code)
	await clear_current_tab.async_register(connection)

    iterm2.run_forever(main)

:Download:`Download<cleartab.its>`

---

### Api Library Python Iterm2 Docs Examples Colorhost (`api/library/python/iterm2/docs/examples/colorhost.rst`)

:orphan:

.. _colorhost_example:

Per-Host Colors
===============

This script sets the color of a session based on the current hostname. For it to work, iTerm2 must know the current hostname. You can do that either by installing Shell Integration or by defining triggers that detect the hostname. More information on that is available here: https://www.iterm2.com/documentation-shell-integration.html

Edit the `colormap` variable to specify the hostname to color preset mapping you prefer.

.. code-block:: python

    #!/usr/bin/env python3.7

    import asyncio
    import iterm2

    colormap = { "example.com": "Dark Background",
                 "Georges-iMac.local": "Light Background" }

    async def SetPresetInSession(connection, session, preset_name):
        """Change the colors in session to the color preset named `preset_name`.
        Does not modify the underlying profile."""
        preset = await iterm2.ColorPreset.async_get(connection, preset_name)
        if not preset:
            return
        profile = await session.async_get_profile()
        if not profile:
            return
        await profile.async_set_color_preset(preset)

    async def MonitorSession(connection, session):
        """Called when a new session is created."""
        hostname = await session.async_get_variable("hostname")
        if hostname in colormap:
            await SetPresetInSession(connection, session, colormap[hostname])

        async with iterm2.VariableMonitor(
                connection,
                iterm2.VariableScopes.SESSION,
                "hostname",
                session.session_id) as mon:
            while True:
                hostname = await mon.async_get()
                if hostname in colormap:
                    await SetPresetInSession(
                            connection,
                            session,
                            colormap[hostname])

    async def main(connection):
        # Monitor existing sessions
        app = await iterm2.async_get_app(connection)
        for window in app.terminal_windows:
            for tab in window.tabs:
                for session in tab.sessions:
                    asyncio.create_task(MonitorSession(connection, session))

        # When new sessions are created, monitor them, too.
        async with iterm2.NewSessionMonitor(connection) as mon:
            while True:
                session_id = await mon.async_get()
                asyncio.create_task(MonitorSession(connection, app.get_session_by_id(session_id)))

    iterm2.run_forever(main)

:Download:`Download<colorhost.its>`

---

### Api Library Python Iterm2 Docs Examples Copycolor (`api/library/python/iterm2/docs/examples/copycolor.rst`)

:orphan:

.. _copycolor_example:

Preserve Tab Color
==================

If you split a session using a different profile. you can end up with two panes that have different tab colors. This script copies the old tab color to the new split pane so they stay in sync.

This script demonstrates how to modify a session's profile without modifying the underlying profile (which might affect other sessions).

.. code-block:: python

    #!/usr/bin/env python3.7

    import iterm2

    async def main(connection):
        async def async_color_in_tab(tab, exclude):
            """Return the tab color of any session that already existed."""
            colors = []
            for session in tab.sessions:
                if session == exclude:
                    continue
                profile = await session.async_get_profile()
                if not profile:
                    continue
                color = profile.tab_color
                if color:
                    return color
            return None


        app = await iterm2.async_get_app(connection)
        async with iterm2.NewSessionMonitor(connection) as mon:
            while True:
                # Wait for a new session to be created
                session_id = await mon.async_get()
                session = app.get_session_by_id(session_id)
                if not session:
                    continue
                window, tab = app.get_tab_and_window_for_session(session)
                if not tab:
                    continue
                color = await async_color_in_tab(tab, session)
                if not color:
                    continue

                # Another session had a color in this tab. Change the tab
                # color property of the new session. Use LocalWriteOnlyProfile
                # and session.async_set_profile_properties to avoid changing the
                # underlying profile.
                change = iterm2.LocalWriteOnlyProfile()
                change.set_tab_color(color)
                await session.async_set_profile_properties(change)

    iterm2.run_forever(main)

:Download:`Download<copycolor.its>`

---

### Api Library Python Iterm2 Docs Examples Create Window (`api/library/python/iterm2/docs/examples/create_window.rst`)

:orphan:

.. _create_window_example:

Create Window — Custom Escape Sequence
======================================

This demonstrates handling a custom escape sequence to perform an action. In
this case, the action is to create a new window. This script is meant to be a
starting point for developing your own custom escape sequence handler.

.. code-block:: python

    import iterm2

    async def main(connection):
        async with iterm2.CustomControlSequenceMonitor(
                connection, "shared-secret", r'^create-window$') as mon:
            while True:
                match = await mon.async_get()
                await iterm2.Window.async_create(connection)

    iterm2.run_forever(main)

:Download:`Download<create_window.its>`

To create a window when this script is running, invoke this command:

.. code-block:: bash

    printf "\033]1337;Custom=id=%s:%s\a" "shared-secret" "create-window"

The *shared-secret* string is used to prevent untrusted code from invoking your
function. For example, if you `cat` a text file, it could include escape
sequences, but they won't work unless they contain the proper secret string.

---

### Api Library Python Iterm2 Docs Examples Current Preset (`api/library/python/iterm2/docs/examples/current_preset.rst`)

:orphan:

.. _current_preset_example:

Get Selected Color Preset
=========================

This script prints to stdout the name of the color preset the current
session is using, or `None` if none of them matches.

To see the output you can either run `pip3 install iterm2` and then execute
this script from the command line or run it from the **Scripts** menu and view
the output in the **Script Console**.

.. code-block:: python

    #!/usr/bin/env python3

    import iterm2

    def ColorsUnequal(profile_color, preset_color):
        return (round(profile_color.red) != round(preset_color.red) or
                round(profile_color.green) != round(preset_color.green) or
                round(profile_color.blue) != round(preset_color.blue) or
                round(profile_color.alpha) != round(preset_color.alpha) or
                profile_color.color_space != preset_color.color_space)

    def ProfileUsesPreset(profile, preset):
        for preset_color in preset.values:
            key = preset_color.key
            profile_color = profile.get_color_with_key(key)
            if ColorsUnequal(profile_color, preset_color):
                return False
        return True

    async def PresetForProfile(connection, profile):
        presets=await iterm2.ColorPreset.async_get_list(connection)
        for preset_name in presets:
            preset=await iterm2.ColorPreset.async_get(connection, preset_name)
            if ProfileUsesPreset(profile, preset):
              return preset_name
        return None


    async def main(connection):
        app = await iterm2.async_get_app(connection)
        session=app.current_terminal_window.current_tab.current_session
        profile=await session.async_get_profile()
        print(await PresetForProfile(connection, profile))

    iterm2.run_until_complete(main)

:Download:`Download<current_preset.its>`

---

### Api Library Python Iterm2 Docs Examples Darknight (`api/library/python/iterm2/docs/examples/darknight.rst`)

:orphan:

.. _darknight_example:

Change Color Preset by Time of Day
==================================

The script changes the color preset based on time of day. Edit the constants at the top to customize it for your needs.

.. code-block:: python

    #!/usr/bin/env python3.7

    import asyncio
    import datetime
    import iterm2

    # Clock time to change colors.
    LIGHT_TIME=(7, 0)
    DARK_TIME=(16, 0)

    # Color presets to use
    LIGHT_PRESET_NAME="Light Background"
    DARK_PRESET_NAME="Dark Background"

    # Profiles to update
    PROFILES=["Default"]

    def datetime_after(t, time):
	today = datetime.datetime(t.year, t.month, t.day, time[0], time[1])
	if today > t:
	    return today
	# Same time tomorrow
	return today + datetime.timedelta(1)


    def next_deadline_after(t):
	light_deadline = datetime_after(t, LIGHT_TIME)
	dark_deadline = datetime_after(t, DARK_TIME)
	if light_deadline < dark_deadline:
	    return (LIGHT_PRESET_NAME, light_deadline)
	return (DARK_PRESET_NAME, dark_deadline)

    def get_duration():
	now = datetime.datetime.now()
	preset_name, deadline = next_deadline_after(now)
	duration = (deadline - now).seconds
	print("Sleep for {} seconds until {}".format(duration, deadline))
	return duration, preset_name

    async def set_colors(connection, preset_name):
	print("Change to preset {}".format(preset_name))
	preset = await iterm2.ColorPreset.async_get(connection, preset_name)
	for partial in (await iterm2.PartialProfile.async_query(connection)):
	    if partial.name in PROFILES:
		await partial.async_set_color_preset(preset)

    async def main(connection):
	while True:
	    duration, preset_name = get_duration()
	    await asyncio.sleep(duration)
	    await set_colors(connection, preset_name)
	    await asyncio.sleep(1)

    iterm2.run_forever(main)

:Download:`Download<darknight.its>`

---

### Api Library Python Iterm2 Docs Examples Diskspace (`api/library/python/iterm2/docs/examples/diskspace.rst`)

:orphan:

.. _diskspace_example:

Free Disk Space Status Bar Component
====================================

This program defines a status bar component that shows the amount of free disk space. It demonstrates a status bar component that shows a possibly expensive-to-compute value that is the same across all instances. To minimize the cost, disk space is measured periodically and saved in an iTerm2 variable in the global scope.

You'll need to place the script in `~/Library/Application Support/iTerm2/Scripts/AutoLaunch`. Then manually launch it or restart the app. Then, navigate to **Preferences > Profiles > Session**. Turn on **Status Bar Enabled** and select **Configure Status Bar**. Drag the **123 Fb 💾** component into the bottom section.

.. code-block:: python

    #!/usr/bin/env python3.7

    import asyncio
    import iterm2
    import os

    def FormatBytes(num, suffix='B'):
        for unit in ['','Ki','Mi','Gi','Ti','Pi','Ei','Zi']:
            if abs(num) < 1024.0:
                return "%3.1f %s%s" % (num, unit, suffix)
            num /= 1024.0
        return "%.1f %s%s" % (num, 'Yi', suffix)

    def GetFreeSpace():
        statvfs = os.statvfs('/')
        return FormatBytes(statvfs.f_frsize * statvfs.f_bavail)

    task = None

    async def main(connection):
        app = await iterm2.async_get_app(connection)

        component = iterm2.StatusBarComponent(
            short_description="Free Space",
            detailed_description="Shows the amount of free disk space",
            knobs=[],
            exemplar="💾 " + FormatBytes(1024 * 1024 * 1024 * 1024 * 2.1),
            update_cadence=None,
            identifier="com.iterm2.example.disk-space")

        async def poll():
            while True:
                space = GetFreeSpace()
                print("Measure disk space")
                await app.async_set_variable("user.diskspace", space)
                await asyncio.sleep(10)

        global task
        task = asyncio.create_task(poll())

        # This function gets called once per second.
        @iterm2.StatusBarRPC
        async def coro(knobs, space=iterm2.Reference("iterm2.user.diskspace?")):
            if space is None:
                return "Measuring"
            return str("💾 " + space)

        # Register the component.
        await component.async_register(connection, coro)

    # This instructs the script to run the "main" coroutine and to keep running even after it returns.
    iterm2.run_forever(main)


:Download:`Download<diskspace.its>`

---

### Api Library Python Iterm2 Docs Examples Enable Broadcasting (`api/library/python/iterm2/docs/examples/enable_broadcasting.rst`)

:orphan:

.. _enable_broadcasting_example:

Enable Broadcasting Input
=========================

This example demonstrates how to manipulate which sessions broadcast input. It turns on input broadcasting for the first session in each tab of the first window.

Input broadcasting happens among the sessions belonging to a particular window.

There may be multiple "broadcast domains". Each broadcast domain has a collection of sessions belonging to a window. There may not be more than one broadcast domain per window.

.. code-block:: python

    #!/usr/bin/env python3

    import iterm2

    async def main(connection):
        app = await iterm2.async_get_app(connection)
        domain = iterm2.broadcast.BroadcastDomain()
        for tab in app.terminal_windows[0].tabs:
            domain.add_session(tab.sessions[0])
        await iterm2.async_set_broadcast_domains(connection, [domain])


    iterm2.run_until_complete(main)

:Download:`Download<enable_broadcasting.its>`

---

### Api Library Python Iterm2 Docs Examples Escindicator (`api/library/python/iterm2/docs/examples/escindicator.rst`)

:orphan:

.. _escindicator_example:

Escape Key Indicator
====================

This script defines a custom status bar control that shows an indicator briefly after you press the escape key. This is useful if you are afflicted with a touch bar.

This script demonstrates a custom status bar component, a keyboard monitor, and using user-defined variables to make the keyboard monitor elicit a change in the status bar. It also demonstrates using asyncio tasks to perform an action after a delay, as well as how to cancel asyncio tasks.

After starting this script, navigate to **Preferences > Profiles > Session**. Turn on **Status Bar Enabled** and select **Configure Status Bar**. Drag the **Esc Key Indicator** component into the bottom section.

This script is a long-running daemon since it must continually update the status bar. As such, it should go in the AutoLaunch folder.

.. code-block:: python

    #!/usr/bin/env python3.7

    import asyncio
    import iterm2

    counter = 0

    async def main(connection):
        app = await iterm2.async_get_app(connection)
        tasks = {}

        component = iterm2.StatusBarComponent(
            short_description="Esc Key Indicator",
            detailed_description="Shows a visual indicator when the esc key is pressed",
            knobs=[],
            exemplar="[esc]",
            update_cadence=None,
            identifier="com.iterm2.escindicator")

        async def reset(session):
            await asyncio.sleep(2)
            await session.async_set_variable("user.showEscIndicator", False)

        async def keystroke_handler(keystroke):
            """This function is called every time a key is pressed."""
            if keystroke.keycode != iterm2.Keycode.ESCAPE:
                return

            # There might not be a current session, so there might be an exception
            # while trying to get it.
            try:
                session = app.current_terminal_window.current_tab.current_session
            except:
                return

            # The status bar coro will only be called when the variable changes,
            # so the value must be different each time. This only matters if you
            # press esc while reset() is still sleeping.
            global counter
            counter += 1
            await session.async_set_variable("user.showEscIndicator", counter)

        # The user variable `showEscIndicator` is used as a communications channel
        # between the keyboard monitor and the status bar component coro. Since it
        # may not always be defined (e.g., when a new session is created) it must be
        # labeled as optional with the trailing ? to prevent an exception.
        @iterm2.StatusBarRPC
        async def coro(
                knobs,
                show_indicator=iterm2.Reference("user.showEscIndicator?"),
                session_id=iterm2.Reference("id")):
            """This function gets called when showEscIndicator changes in any
            session."""
            if show_indicator:
                if session_id in tasks:
                    tasks[session_id].cancel()
                    del tasks[session_id]

                task = asyncio.create_task(reset(app.get_session_by_id(session_id)))
                tasks[session_id] = task
                return "[ESC]"
            else:
                return "     "

        # Register the status bar component.
        await component.async_register(connection, coro)

        # Monitor the keyboard
        async with iterm2.KeystrokeMonitor(connection) as mon:
            while True:
                keystroke = await mon.async_get()
                await keystroke_handler(keystroke)

    iterm2.run_forever(main)

:Download:`Download<escindicator.its>`

---

### Api Library Python Iterm2 Docs Examples Findps (`api/library/python/iterm2/docs/examples/findps.rst`)

:orphan:

.. _findps_example:

Find Pane with Process
----------------------

This script asks the user to input a process ID and then reveals the pane that contains it, if any.

It depends on `psutil`. If you create this manually, create a full environment script and specify `psutil` as a dependency.

.. code-block:: python

    #!/usr/bin/env python3.7

    import iterm2
    import psutil

    async def main(connection):
        app = await iterm2.async_get_app(connection)

        alert = iterm2.TextInputAlert(
            "Search for process",
            "Enter a process ID to reveal the pane containing it.",
            "Enter the process ID to search for", "")
        query = await alert.async_run(connection)
        try:
          query = int(query)
        except:
          return

        desired = []
        try:
          while query > 1:
            desired.append(query)
            parent = psutil.Process(query).ppid()
            query = parent
        except:
          pass

        for window in app.windows:
          for tab in window.tabs:
            for session in tab.sessions:
              pid = await session.async_get_variable("pid")
              if pid in desired:
                await session.async_activate()
                return

    iterm2.run_until_complete(main)

:Download:`Download<findps.its>`

---

### Api Library Python Iterm2 Docs Examples Fs Only Status Bar (`api/library/python/iterm2/docs/examples/fs-only-status-bar.rst`)

:orphan:

.. _fsonlystatusbar_example:

Show Status Bar Only in Full Screen Windows
============================================

This script automatically shows or hides the status bar depending on the window's style. Full screen windows get a status bar, and regular windows do not. It demonstrates a few concepts:

  * Watching for window style changes.
  * Performing an action on all windows, including those not yet created.
  * Using asyncio to run multiple tasks concurrently.
  * Changing a profile setting in a session without updating the underlying profile.

.. code-block:: python

    #!/usr/bin/env python3.7

    import asyncio
    import iterm2

    async def main(connection):
        app = await iterm2.async_get_app(connection)

        async def set_show_status_bar(w, show):
            """Show or hide the status bar for one window by updating the profiles
            of all sessions in that window."""
            change = iterm2.LocalWriteOnlyProfile()
            change.set_status_bar_enabled(show)
            tasks = []
            for tab in w.tabs:
                for session in tab.sessions:
                    tasks.append(session.async_set_profile_properties(change))
            await asyncio.gather(*tasks)

        async def update():
            """Update whether the status bar is shown for all sessions in all windows."""
            tasks = []
            for w in app.terminal_windows:
                style = await w.async_get_variable("style")
                if style == "non-native full screen" or style == "native full screen":
                    tasks.append(set_show_status_bar(w, True))
                else:
                    tasks.append(set_show_status_bar(w, False))
            if tasks:
                await asyncio.gather(*tasks)

        async def watch_for_style_changes():
            """A task that calls `update` when a window's style changes."""
            async with iterm2.VariableMonitor(connection, iterm2.VariableScopes.WINDOW, "style", "all") as mon:
                while True:
                    theme = await mon.async_get()
                    await update()

        async def watch_for_layout_changes():
            """A task that calls `update` when the layout changes (new window
            created, session moves from one window to another, etc.)"""
            async with iterm2.LayoutChangeMonitor(connection) as mon:
                while True:
                    await mon.async_get()
                    await update()

        # Set status bars for existing windows
        await update()

        # Monitor changes to styles in windows.
        asyncio.create_task(watch_for_style_changes())

        # Monitor for new windows or sessions moving from one window to another.
        asyncio.create_task(watch_for_layout_changes())

    iterm2.run_forever(main)

:Download:`Download<fs-only-status-bar.its>`

---

### Api Library Python Iterm2 Docs Examples Function Key Tabs (`api/library/python/iterm2/docs/examples/function_key_tabs.rst`)

:orphan:

.. _function_key_tabs_example:

Function Key Tabs
=================

The script makes it possible to select a tab by pressing a function key. F1 chooses the first tab, F2 the second, etc.

.. code-block:: python

    #!/usr/bin/env python3

    import asyncio
    import iterm2

    async def main(connection):
        app = await iterm2.async_get_app(connection)
        keycodes = [ iterm2.Keycode.F1,
                     iterm2.Keycode.F2,
                     iterm2.Keycode.F3,
                     iterm2.Keycode.F4,
                     iterm2.Keycode.F5,
                     iterm2.Keycode.F6,
                     iterm2.Keycode.F7,
                     iterm2.Keycode.F8,
                     iterm2.Keycode.F9,
                     iterm2.Keycode.F10,
                     iterm2.Keycode.F11,
                     iterm2.Keycode.F12 ]
        async def keystroke_handler(connection, keystroke):
            if keystroke.modifiers == [ iterm2.Modifier.FUNCTION ]:
                try:
                  fkey = keycodes.index(keystroke.keycode)
                  if fkey >= 0 and fkey < len(app.current_terminal_window.tabs):
                      await app.current_terminal_window.tabs[fkey].async_select()
                except:
                  pass


        pattern = iterm2.KeystrokePattern()
        pattern.forbidden_modifiers.extend([iterm2.Modifier.CONTROL,
                                            iterm2.Modifier.OPTION,
                                            iterm2.Modifier.COMMAND,
                                            iterm2.Modifier.SHIFT,
                                            iterm2.Modifier.NUMPAD])
        pattern.required_modifiers.extend([iterm2.Modifier.FUNCTION])
        pattern.keycodes.extend(keycodes)

        async def monitor():
            async with iterm2.KeystrokeMonitor(connection) as mon:
                while True:
                    keystroke = await mon.async_get()
                    await keystroke_handler(connection, keystroke)
        # Run the monitor in the background
        asyncio.create_task(monitor())

        # Block regular handling of function keys
        filter = iterm2.KeystrokeFilter(connection, [pattern])
        async with filter as mon:
            await iterm2.async_wait_forever()

    iterm2.run_forever(main)


:Download:`Download<function_key_tabs.its>`

---

### Api Library Python Iterm2 Docs Examples Georges Title (`api/library/python/iterm2/docs/examples/georges_title.rst`)

:orphan:

.. _georges_title_example:

George's Title Algorithm
=========================

This code combines user-defined variables from Shell Integration with a custom session title function. It demonstrates :func:`iterm2.registration.TitleProviderRPC`. The end result is a snazzy title that includes your git branch:

.. image:: georgesalgo.png
  :height: 32px
  :width: 375px

|
First, you need to install Shell Integration. Add this to your .bashrc:

.. code-block:: bash

    function iterm2_print_user_vars() {
      iterm2_set_user_var gitBranch $((git branch 2> /dev/null) | grep \* | cut -c3-)
      iterm2_set_user_var home $(echo -n "$HOME")
    }

Next, install this script in `~/Library/Application Support/iTerm2/Scripts/AutoLaunch`:

.. code-block:: python

    #!/usr/bin/env python3

    import asyncio
    import iterm2
    import os
    import subprocess
    import sys

    def hostname_dash_f():
        process = subprocess.Popen(["hostname", "-f"], stdout=subprocess.PIPE)
        (output, err) = process.communicate()
        exit_code = process.wait()
        return output.decode('utf-8').rstrip()

    def shortened_hostname(h):
        i = h.find(".")
        if i < 0:
            return h
        else:
            return h[:i]

    def make_title(auto_name, profile_name):
        if auto_name != profile_name:
            return auto_name
        else:
            return ""

    def make_hostname(hostname, localhost):
        if not hostname:
            return ""
        short_hostname = shortened_hostname(hostname)
        if short_hostname and short_hostname != localhost:
            return "➥ " + short_hostname
        return ""

    def make_pwd(user_home, localhome, pwd):
        if pwd:
            if user_home:
                home = user_home
            else:
                home = localhome
            home_prefix = "📂 "
            if pwd == home:
                home_prefix = ""
                pwd = "🏡"
            elif pwd.startswith(home):
                pwd = "~" + pwd[len(home):]
            if pwd:
                return home_prefix + pwd
        return ""

    def make_branch(branch):
        if branch:
            return " ⎇ " + branch.rstrip()
        return ""

    async def main(connection):
        localhome = os.environ.get("HOME")
        localhost = hostname_dash_f()

        @iterm2.TitleProviderRPC
        async def georges_title(
            pwd=iterm2.Reference("path?"),
            hostname=iterm2.Reference("hostname?"),
            branch=iterm2.Reference("user.gitBranch?"),
            auto_name=iterm2.Reference("autoName?"),
            profile_name=iterm2.Reference("profileName?"),
            tmux_title=iterm2.Reference("tmuxWindowTitle?"),
            user_home=iterm2.Reference("user.home?")):
            if tmux_title:
                return tmux_title

            parts = [make_title(auto_name, profile_name),
                     make_hostname(hostname, localhost),
                     make_pwd(user_home, localhome, pwd),
                     make_branch(branch)]
            return " ".join(list(filter(lambda x: x, parts)))
        await georges_title.async_register(
                connection,
                display_name="George's Title Algorithm",
                unique_identifier="com.iterm2.example.georges-title-algorithm")

    iterm2.run_forever(main)

:Download:`Download<georges_title.its>`

Then either restart iTerm2 or launch the script from **Scripts > AutoLaunch > georges_title**.

Finally, select *George's Title Algorithm* in **Prefs > Profiles > General > Title**.

---

### Api Library Python Iterm2 Docs Examples Gmtclock (`api/library/python/iterm2/docs/examples/gmtclock.rst`)

:orphan:

.. _gmtclock_example:

GMT Clock
=========

This script demonstrates a custom status bar component that shows the time in GMT, updating once a second.

.. code-block:: python

    import iterm2
    import datetime

    async def main(connection):
    component = iterm2.StatusBarComponent(
        short_description="GMT Clock",
        detailed_description="Shows the time in jolly old England",
        knobs=[],
        exemplar="[12:00 GMT]",
        update_cadence=1,
        identifier="com.iterm2.example.gmt-clock")

    # This function gets called once per second.
    @iterm2.StatusBarRPC
    async def coro(knobs):
        return datetime.datetime.now(datetime.timezone.utc).strftime("%H:%M:%S GMT")

    # Register the component.
    await component.async_register(connection, coro)

    iterm2.run_forever(main)

:Download:`Download<gmtclock.its>`

---

### Api Library Python Iterm2 Docs Examples Increase Font Size (`api/library/python/iterm2/docs/examples/increase_font_size.rst`)

:orphan:

.. _increase_font_size_example:

Increase Font Size By 6
=======================

This script increases the font size of a session by six
points. It demonstrates changing a session's profile without
updating the underlying profile. It also demonstrates
parsing and modifying font settings, as well as registering
an RPC.

You can bind it to a keystroke by adding a new key binding
in **Prefs > Keys**, selecting the action **Invoke Script
Function**, and giving it the invocation
`increase_font_size(session_id: id)`.

.. code-block:: python

    #!/usr/bin/env python3.7

    import iterm2
    import re

    async def main(connection):
        app = await iterm2.async_get_app(connection)
        # This regex splits the font into its name and size. Fonts always end with
        # their size in points, preceded by a space.
        r = re.compile(r'^([^ ]* )(\d*)(.*)$')

        @iterm2.RPC
        async def increase_font_size(session_id):
            session = app.get_session_by_id(session_id)
            if not session:
                return
            # Get the session's profile because we need to know its font.
            profile = await session.async_get_profile()

            # Extract the name and point size of the font using a regex.
            font = profile.normal_font
            match = r.search(font)
            if not match:
                return
            groups = match.groups()
            name = groups[0]
            size = int(groups[1])
            remainder = groups[2]

            # Prepare an update to the profile that increases the font size
            # by 6 points.
            replacement = name + str(size + 6) + remainder
            change = iterm2.LocalWriteOnlyProfile()
            change.set_normal_font(replacement)

            # Update the session's copy of its profile without updating the
            # underlying profile.
            await session.async_set_profile_properties(change)
        await increase_font_size.async_register(connection)

    iterm2.run_forever(main)

:Download:`Download<increase_font_size.its>`

---

### Api Library Python Iterm2 Docs Examples Index (`api/library/python/iterm2/docs/examples/index.rst`)

:orphan:

.. _examples-index:
.. Example Scripts

Example Scripts
===============

Here are a collection of working scripts for you to crib from. While they are categorized according to their main function, some of them demonstrate more than one scripting feature. Search through this page or look for **See Also** sections in the documentation that link to examples demonstrating particular APIs.

----

**Session Title Providers**

:doc:`georges_title` — Demonstrates a complex session title provider.

:doc:`badgetitle` — Demonstrates a simple session title provider.

----

**Status Bar Components**

:doc:`statusbar` — Demonstrates a status bar component with variable-length text and a configurable knob.

:doc:`escindicator` — Demonstrates monitoring for keystrokes, custom status bar components, and using variables as a back-channel for communication between parts of a script.

:doc:`jsonpretty` — Demonstrates a status bar component that handles clicks and opens a popover with a web view.

:doc:`mousemode` — Demonstrates a status bar component that responds to changes in a variable.

:doc:`gmtclock` - Demonstrates a status bar component that shows the current time in the GMT time zone.

:doc:`diskspace` - Demonstrates a status bar component that updates itself periodically, showing the amount of free disk space.

:doc:`unread` - Demonstrates a status bar component with an icon and an "unread count".

:doc:`weather` - Demonstrates fetching a web page periodically and showing its data in a custom status bar component. Also demonstrates providing an icon for a custom status bar component.

:doc:`venv` - A status bar component that shows the current Python virtual environment.

----

**Tmux**

:doc:`tmux` — Demonstrates basic functions of the tmux integration API.

:doc:`tile` — Demonstrates sending a command to the tmux server in tmux integration mode.


----

**Monitoring for Events**

:doc:`random_color` — Demonstrates performing an action when a new session is created and using a color preset.

:doc:`colorhost` — Demonstrates monitoring for different kinds of events concurrently.

:doc:`fs-only-status-bar` - Demonstrates monitoring for the creation of windows and the change of window style.

:doc:`theme` — Demonstrates monitoring a variable and using color presets.

:doc:`copycolor` — Demonstrates monitoring for session creation and using color presets.

:doc:`tabtitle` - Demonstrates monitoring for the creation of a new tab. Also demonstrates prompting the user for a string and changing a tab title.

:doc:`autoalert` - Demonstrates monitoring all sessions for long-running jobs. Also demonstrates posting notifications.

:doc:`stty` - Demonstrates watching for a variable to change in all sessions and sending text in response.

:doc:`app_tab_color` - Demonstrates watching for changes in the current foreground job. Updates the tab color as a function of the current command.

:doc:`sync_title` — Monitors for changes to a pane's title and copies it to the tab title. Demonstrates monitoring for changes to a variable and setting variables.

----

**Profiles and Color Presets**

:doc:`current_preset` — Demonstrates getting a session's profile and querying the list of color presets.

:doc:`blending` — Demonstrates registering a function and adjusting profiles' values.

:doc:`settabcolor` — Demonstrates changing a session's local profile without updating the underlying profile.

:doc:`increase_font_size` — Demonstrates changing a session's font without updating the underlying profile.

:doc:`resizeall` - Demonstrates registering a function that changes the font of all sessions in a window.

:doc:`change_default_profile` - Demonstrates changing the default profile.

:doc:`setprofile` - Demonstrates changing a session's profile.

----

**Standalone Scripts**

:doc:`set_title_forever` — Demonstrates setting a session's name. Also demonstrates a script that's meant to be run from the command line that will launch iTerm2 and wait until it's able to connect before proceeding.

:doc:`launch_and_run` — Demonstrates launching iTerm2 from the command line (if needed) and creating a new window that runs a command.

:doc:`runcommand` — Demonstrates running a command and capturing its output.

----

**Keyboard**

:doc:`function_key_tabs` — Demonstrates changing the behavior of a keystroke.


----

**Broadcasting Input**

:doc:`enable_broadcasting` — Demonstrates broadcast domains.

:doc:`broadcast` — Demonstrates splitting panes, broadcast domains, filtering keystrokes, and sending input.


----

**Windows and Tabs**

:doc:`movetab` — Demonstrates moving tabs among windows.

:doc:`apply_layout` — Demonstrates moving sessions between tabs, windows, and split panes via :meth:`iterm2.App.async_apply_layout`.

:doc:`sorttabs` — Demonstrates reordering tabs in a window.

:doc:`mrutabs` — Demonstrates monitoring for changes in keyboard focus and reordering tabs in a window. This script keeps tabs always in most-recently-used order, so the first tab is always selected.

:doc:`mrutabs2` - This script selects the next-most-recently-used tab when the current tab closes. Same for split panes.

:doc:`findps` - This script shows an alert prompting the user to enter a process ID and then reveals the pane that contains it.

:doc:`tab_group_test` — Demonstrates the tab group API: creating groups, adding and removing tabs, and renaming, recoloring, and collapsing groups.


----

**Asyncio**

:doc:`close_to_the_right` — Demonstrates asyncio.gather to perform actions in parallel.

:doc:`darknight` — Demonstrates performing an action at a particular time of day.


----

**Custom Toolbelt Tools**

:doc:`targeted_input` — Demonstrates custom toolbelt tool, broadcast domains, and sending input.


----

**Custom Context Menu Items**

:doc:`sumselection` - Demonstrates a custom context menu item that calculates the sum of selected numbers.


----

**Selection**

:doc:`zoom_on_screen` — Demonstrates selecting a menu item and modifying the selection.


----

**Other**

:doc:`cls` — Demonstrates registering a function, injecting a control sequence, and iterating over sessions.

:doc:`create_window` — Demonstrates custom control sequences.

:doc:`ccs` — Demonstrates custom control sequences that identify the session that received the sequence.

:doc:`oneshot` — Demonstrates registering a function and showing a modal alert.

:doc:`open_browser_tab` — Demonstrates creating a browser tab and loading a URL in it.

----

++++++++++++++
Other Sections
++++++++++++++

* :doc:`/index`

Indices and tables
==================

* :ref:`genindex`
* :ref:`search`

---

### Api Library Python Iterm2 Docs Examples Jsonpretty (`api/library/python/iterm2/docs/examples/jsonpretty.rst`)

:orphan:

.. _jsonpretty_example:

JSON Pretty Printer Status Bar Component
========================================

This script demonstrates a status bar component that can get the selected text, define an on-click handler, and open a popover showing HTML.

See :doc:`statusbar` for instructions on installing a custom status bar component.

.. code-block:: python

    import iterm2
    import json

    def tohtml(text):
        return "<pre>" + text.replace("&", "&amp;").replace("<", "&lt;") + "</pre>"

    def prettyprint(text):
        try:
            root = json.loads(text)
        except json.decoder.JSONDecodeError as e:
            return "Invalid JSON: {}".format(e)
        return json.dumps(root, sort_keys=True, indent=4)

    async def main(connection):
        app=await iterm2.async_get_app(connection)

        # Set the click handler
        @iterm2.RPC
        async def onclick(session_id):
            session = app.get_session_by_id(session_id)
            selection = await session.async_get_selection()
            selectedText = await session.async_get_selection_text(selection)
            await component.async_open_popover(session_id, tohtml(prettyprint(selectedText)), iterm2.util.Size(600, 600))

        # Define the configuration knobs:
        vl = "json_pretty_printer"
        knobs = [iterm2.CheckboxKnob("JSON Pretty Printer", False, vl)]
        component = iterm2.StatusBarComponent(
            short_description="JSON Pretty Printer",
            detailed_description="Select JSON in the terminal, then click this status bar component to see it nicely formatted.",
            knobs=knobs,
            exemplar="{ JSON }",
            update_cadence=None,
            identifier="com.iterm2.json-pretty-printer")

        # This function gets called whenever any of the paths named in defaults (below) changes
        # or its configuration changes.
        @iterm2.StatusBarRPC
        async def coro(knobs):
            return ["{ JSON }"]

        # Register the component.
        await component.async_register(connection, coro, onclick=onclick)

    iterm2.run_forever(main)

:Download:`Download<jsonpretty.its>`

---

### Api Library Python Iterm2 Docs Examples Launch And Run (`api/library/python/iterm2/docs/examples/launch_and_run.rst`)

:orphan:

.. _launch_and_run_example:

Launch iTerm2 and Run Command
=============================

This script demonstrates two concepts:

1. Launching iTerm2 using PyObjC and running the script only
   after it is launched.
2. Creating a window that runs a command.

Launching the app is useful when the script is run from the
command line rather than from within iTerm2. To run this
script from the command line you'll need to install its
dependencies first:

.. code-block:: bash

    brew install python3
    pip3 install iterm2
    pip3 install pyobjc

Here's the code:

.. code-block:: python

    #!/usr/bin/env python3

    import iterm2
    import AppKit

    # Launch the app
    AppKit.NSWorkspace.sharedWorkspace().launchApplication_("iTerm2")

    async def main(connection):
        app = await iterm2.async_get_app(connection)

        # Foreground the app
        await app.async_activate()

        # This will run 'vi' from bash. If you use a different shell, you'll need
        # to change it here. Running it through the shell sets up your $PATH so you
        # don't need to specify a full path to the command.
        await iterm2.Window.async_create(connection, command="/bin/bash -l -c vi")

    # Passing True for the second parameter means keep trying to
    # connect until the app launches.
    iterm2.run_until_complete(main, True)

:Download:`Download<launch_and_run.py>`

---

### Api Library Python Iterm2 Docs Examples Mousemode (`api/library/python/iterm2/docs/examples/mousemode.rst`)

:orphan:

.. _mousemode_example:

Status Bar Component: Mouse Mode
================================

Like :doc:`statusbar`, this demonstrates a custom status bar component. The
difference is that this one displays the value of a variable: the mouse
reporting status.

After starting this script, navigate to **Preferences > Profiles > Session**.
Turn on **Status Bar Enabled** and select **Configure Status Bar**. Drag the
**Mouse Mode** component into the bottom section.

This script is a long-running daemon since the registered function gets called
whenever the size of a session changes. As such, it should go in the AutoLaunch
folder.

.. code-block:: python

    import asyncio
    import iterm2

    async def main(connection):
        component = iterm2.StatusBarComponent(
            short_description="Mouse Mode",
            detailed_description="Indicates if mouse reporting is enabled",
            knobs=[],
            exemplar="[mouse on]",
            update_cadence=None,
            identifier="com.iterm2.example.mouse-mode")

        # This function gets called whenever any of the paths named in defaults (below) changes
        # or its configuration changes. This will be called when mouseReportingMode changes.
        @iterm2.StatusBarRPC
        async def coro(
                knobs,
                reporting=iterm2.Reference("mouseReportingMode")):
            if reporting < 0:
                return " "
            else:
                return "🐭"

        # Register the component.
        await component.async_register(connection, coro)

    iterm2.run_forever(main)

:Download:`Download<mousemode.its>`

---

### Api Library Python Iterm2 Docs Examples Movetab (`api/library/python/iterm2/docs/examples/movetab.rst`)

:orphan:

.. _movetab_example:

Move Tab To Next/Previous Window
================================

This script registers a pair of functions to move the current tab to the next or previous window.

You can bind it to a keystroke in **Prefs > Keys** by selecting the action *Invoke Script Function* and giving it the invocation `move_current_tab_to_next_window()` or `move_current_tab_to_previous_window()`.

.. code-block:: python

    #!/usr/bin/env python3.7
    import iterm2

    async def main(connection):
        app = await iterm2.async_get_app(connection)

        async def move_current_tab_by_n_windows(delta):
            tab_to_move = app.current_terminal_window.current_tab
            window_with_tab_to_move = app.get_window_for_tab(tab_to_move.tab_id)
            i = app.terminal_windows.index(window_with_tab_to_move)
            n = len(app.terminal_windows)
            j = (i + delta) % n
            if i == j:
                return
            window = app.terminal_windows[j]
            await window.async_set_tabs(window.tabs + [tab_to_move])

        @iterm2.RPC
        async def move_current_tab_to_next_window():
            await move_current_tab_by_n_windows(1)
        await move_current_tab_to_next_window.async_register(connection)

        @iterm2.RPC
        async def move_current_tab_to_previous_window():
            n = len(app.terminal_windows)
            if n > 0:
                await move_current_tab_by_n_windows(n - 1)
        await move_current_tab_to_previous_window.async_register(connection)

    iterm2.run_forever(main)

:Download:`Download<movetab.its>`

---

### Api Library Python Iterm2 Docs Examples Mrutabs (`api/library/python/iterm2/docs/examples/mrutabs.rst`)

:orphan:

.. _mrutabs_example:

Persistent MRU Tabs
===================

This script keeps tabs in most-recently used order, with the current tab always first.

.. code-block:: python

    #!/usr/bin/env python3

    import asyncio
    import iterm2

    async def main(connection):
        app = await iterm2.async_get_app(connection)

        async def reorder_tabs():
          window = app.current_terminal_window
          if not window:
              return
          if window.current_tab != window.tabs[0]:
              tabs = list(window.tabs)
              i = tabs.index(window.current_tab)
              del tabs[i]
              tabs.insert(0, window.current_tab)
              await window.async_set_tabs(tabs)

        async with iterm2.FocusMonitor(connection) as monitor:
            while True:
                update = await monitor.async_get_next_update()
                if update.selected_tab_changed:
                    await reorder_tabs()

    iterm2.run_forever(main)

:Download:`Download<mrutabs.its>`

---

### Api Library Python Iterm2 Docs Examples Mrutabs2 (`api/library/python/iterm2/docs/examples/mrutabs2.rst`)

:orphan:

.. _mrutabs2_example:

Select MRU On Close
===================

When the current tab or session closes, this script selects the next most recently used tab or session.

.. code-block:: python

    #!/usr/bin/env python3.7
    #
    # This script selects the most recently used tab or split pane when the current
    # tab or split pane closes.

    import iterm2

    window_id_to_tab_ids = {}
    tab_id_to_session_ids = {}

    def init_window(window):
        """Initialize the window -> tab list for one window"""
        global window_id_to_tab_ids
        window_id_to_tab_ids[window.window_id] = list(map(lambda x: x.tab_id, window.tabs))

        for tab in window.tabs:
            init_tab(tab)

    def init_tab(tab):
        """Initialize the tab -> session list for one tab"""
        global tab_id_to_session_ids
        tab_id_to_session_ids[tab.tab_id] = list(map(lambda x: x.session_id, tab.sessions))

    def init_window_if_needed(w):
        """Record the tab order in window w if it doesn't already exist."""
        global window_id_to_tab_ids
        if w.window_id in window_id_to_tab_ids:
            return
        init_window(w)

    def init_tab_if_needed(t):
        """Record the tab order in tab t if it doesn't already exist."""
        global tab_id_to_session_ids
        if t.tab_id in tab_id_to_session_ids:
            return
        init_tab(t)

    def refresh_window(window):
        """Remove defunct tabs and add new tabs to tab list for window"""
        global window_id_to_tab_ids
        tab_ids = list(map(lambda x: x.tab_id, window.tabs))

        # Remove defunct tabs
        if window.window_id in window_id_to_tab_ids:
            existing = window_id_to_tab_ids[window.window_id] 
        else:
            existing  = []
        updated = list(filter(lambda x: x in tab_ids, existing))

        # Add any newly discovered tabs to the end
        for t in window.tabs:
            if t.tab_id not in updated:
                updated.append(t.tab_id)

        window_id_to_tab_ids[window.window_id] = updated

    def refresh_tab(tab):
        """Remove defunct sessions and add new sessions to session list for tab"""
        global tab_id_to_session_ids
        session_ids = list(map(lambda x: x.session_id, tab.sessions))

        # Remove defunct sessions
        if tab.tab_id in tab_id_to_session_ids:
            existing = tab_id_to_session_ids[tab.tab_id]
        else:
            existing = []
        updated = list(filter(lambda x: x in session_ids, existing))

        # Add any newly discovered sessions to the end
        for s in tab.sessions:
            if s.session_id not in updated:
                updated.append(s.session_id)

        tab_id_to_session_ids[tab.tab_id] = updated

    def get_mru_tab_id(window):
        """Returns the most recently used tab ID in this window"""
        global window_id_to_tab_ids
        if window.window_id not in window_id_to_tab_ids:
            return None
        tab_ids = window_id_to_tab_ids[window.window_id]
        if len(tab_ids) == 0:
            return None
        return tab_ids[0]

    def get_mru_session_id(tab):
        """Returns the most recently used session ID in this tab"""
        global tab_id_to_session_ids
        if tab.tab_id not in tab_id_to_session_ids:
            return None
        session_ids = tab_id_to_session_ids[tab.tab_id]
        if len(session_ids) == 0:
            return None
        return session_ids[0]

    def get_successor_tab_id(window, tab_id):
        """When a tab is closed, select the next most recently used tab. Remove any defunct tabs from the MRU list."""
        refresh_window(window)
        mru_tab_id = get_mru_tab_id(window)
        if not mru_tab_id:
            return None
        if mru_tab_id == tab_id:
            return None
        return mru_tab_id

    def get_successor_session_id(session, tab):
        """When a session is closed, select the next most recently used session. Remove any defunct sessions from the MRU list."""
        refresh_tab(tab)
        mru_session_id = get_mru_session_id(tab)
        if not mru_session_id:
            return None
        if mru_session_id == session.session_id:
            return None
        return mru_session_id

    def update_mru_tab(window_id, tab_id):
        """When a tab gets selected, move it to the head of the MRU list"""
        global window_id_to_tab_ids
        if window_id in window_id_to_tab_ids:
            ids = window_id_to_tab_ids[window_id]
        else:
            ids = []
        if tab_id in ids:
            i = ids.index(tab_id)
            del ids[i]
        ids.insert(0, tab_id)
        window_id_to_tab_ids[window_id] = ids

    def update_mru_session(tab_id, session_id):
        """When a session gets selected, move it to the head of the MRU list"""
        global tab_id_to_session_ids
        if tab_id in tab_id_to_session_ids:
            ids = tab_id_to_session_ids[tab_id]
        else:
            ids = []
        if session_id in ids:
            i = ids.index(session_id)
            del ids[i]
        ids.insert(0, session_id)
        tab_id_to_session_ids[tab_id] = ids

    def tab_known(tab_id, window):
        """Do we already know about this tab and window combination?"""
        global window_id_to_tab_ids
        if window.window_id not in window_id_to_tab_ids:
            return False
        return tab_id in window_id_to_tab_ids[window.window_id]

    def session_known(session_id, tab):
        """Do we already know about this session and tab combination?"""
        global tab_id_to_session_ids
        if tab.tab_id not in tab_id_to_session_ids:
            return False
        return session_id in tab_id_to_session_ids[tab.tab_id]

    def window_has_closed_tabs(window):
        """Are there tab IDs in the MRU list not in the actual set of tabs?"""
        global window_id_to_tab_ids
        actual_tab_ids = list(map(lambda x: x.tab_id, window.tabs))
        for mru_tab_id in window_id_to_tab_ids[window.window_id]:
            if mru_tab_id not in actual_tab_ids:
                return True
        return False

    def tab_has_closed_sessions(tab):
        """Are there session IDs in the MRU list not in the actual set of sessions?"""
        global tab_id_to_session_ids
        actual_session_ids = list(map(lambda x: x.session_id, tab.sessions))
        for mru_session_id in tab_id_to_session_ids[tab.tab_id]:
            if mru_session_id not in actual_session_ids:
                return True
        return False

    def add_tab_to_window(window_id, tab_id):
        """Add a tab ID to the MRU list for a window."""
        global window_id_to_tab_ids
        if window_id in window_id_to_tab_ids:
            ids = window_id_to_tab_ids[window_id]
        else:
            ids = []
        ids.insert(0, tab_id)
        window_id_to_tab_ids[window_id] = ids

    def add_session_to_tab(tab_id, session_id):
        """Add a session ID to the MRU list for a tab."""
        global tab_id_to_session_ids
        if tab_id in tab_id_to_session_ids:
            ids = tab_id_to_session_ids[tab_id]
        else:
            ids = []
        ids.insert(0, session_id)
        tab_id_to_session_ids[tab_id] = ids

    async def main(connection):
        app = await iterm2.async_get_app(connection)
        for window in app.terminal_windows:
            init_window(window)

        async def handle_close_tab(window, tab_id):
            """A tab was closed"""
            mru_tab_id = get_successor_tab_id(window, tab_id)
            if not mru_tab_id:
                return
            tab = app.get_tab_by_id(mru_tab_id)
            if tab:
                await tab.async_select()

        async def handle_close_session(session, tab):
            """A session was closed"""
            mru_session_id = get_successor_session_id(session, tab)
            if not mru_session_id:
                return
            session = app.get_session_by_id(mru_session_id)
            if session:
                await session.async_activate()

        async def handle_selected_tab_changed(tab_id):
            """The selected tab changed"""
            tab = app.get_tab_by_id(update.selected_tab_changed.tab_id)
            if not tab:
                return

            window = app.get_window_for_tab(tab_id)
            if not window:
                return

            init_tab_if_needed(tab)
            init_window_if_needed(window)
            if not tab_known(tab_id, window):
                add_tab_to_window(window.window_id, tab_id)
                return

            if window_has_closed_tabs(window):
                await handle_close_tab(window, tab_id)
            else:
                update_mru_tab(window.window_id, tab_id)

        def handle_window_became_key(window_id):
            """A window got keyboard focus"""
            w = app.get_window_by_id(window_id)
            if w:
                init_window_if_needed(w)

        async def handle_session_selected(session_id):
            """The selected session changed"""
            s = app.get_session_by_id(session_id)
            if not s:
                return
            window, tab = app.get_tab_and_window_for_session(s)
            if not tab:
                return

            init_tab_if_needed(tab)
            init_window_if_needed(window)
            if not session_known(session_id, tab):
                add_session_to_tab(tab.tab_id, s.session_id)
                return

            if tab_has_closed_sessions(tab):
                await handle_close_session(s, tab)
            else:
                update_mru_session(tab.tab_id, s.session_id)

        # Watch for changes to keyboard focus and update state and active tab/session as needed.
        async with iterm2.FocusMonitor(connection) as monitor:
            while True:
                update = await monitor.async_get_next_update()
                if update.selected_tab_changed:
                    await handle_selected_tab_changed(update.selected_tab_changed.tab_id)
                    continue
                if update.active_session_changed:
                    await handle_session_selected(update.active_session_changed.session_id)
                    continue
                if (update.window_changed and 
                        update.window_changed.event == iterm2.FocusUpdateWindowChanged.Reason.TERMINAL_WINDOW_BECAME_KEY):
                    handle_window_became_key(update.window_changed.window_id)
                    continue

    iterm2.run_forever(main)

:Download:`Download<mrutabs2.its>`

---

### Api Library Python Iterm2 Docs Examples Oneshot (`api/library/python/iterm2/docs/examples/oneshot.rst`)

:orphan:

.. _oneshot_example:

One-Shot Alert
==============

This script registers a function that shows an alert. It's useful as a trigger with the **Invoke Script Function** action. It will only fire once per process, so if one program causes the trigger to fire multiple times you will only get alerted once.

.. code-block:: python

    #!/usr/bin/env python3.7

    import iterm2

    pids = []

    async def main(connection):
	@iterm2.RPC
	async def oneshot_alert(
		title,
		subtitle,
		pid=iterm2.Reference("jobPid")):
	    global pids
	    if pid in pids:
		return
	    pids.append(pid)
	    alert = iterm2.Alert(title, subtitle)
	    await alert.async_run(connection)
	await oneshot_alert.async_register(connection)

    iterm2.run_forever(main)


:Download:`Download<oneshot.its>`

---

### Api Library Python Iterm2 Docs Examples Open Browser Tab (`api/library/python/iterm2/docs/examples/open_browser_tab.rst`)

:orphan:

.. _open_browser_tab_example:

Open a URL in a Browser Tab
===========================

This script creates a new tab configured as a web browser and loads a URL in it.

A profile becomes a browser profile by setting its ``Custom Command`` value to ``Browser``. The example passes that customization to :meth:`~iterm2.window.Window.async_create_tab` and then calls :meth:`~iterm2.session.Session.async_load_url` on the resulting session.

The first time a domain is loaded, iTerm2 prompts the user to approve it. Once approved, the domain is remembered globally and will not prompt again.

.. code-block:: python

    #!/usr/bin/env python3
    import sys
    import iterm2


    async def main(connection):
        url = sys.argv[1] if len(sys.argv) > 1 else "https://example.com/"

        app = await iterm2.async_get_app(connection)
        window = app.current_terminal_window
        if window is None:
            window = await iterm2.Window.async_create(connection)

        customizations = iterm2.LocalWriteOnlyProfile()
        customizations.set_use_custom_command("Browser")

        tab = await window.async_create_tab(profile_customizations=customizations)
        if tab is None or tab.current_session is None:
            print("Failed to create browser tab.", file=sys.stderr)
            return

        await tab.current_session.async_load_url(url)


    iterm2.run_until_complete(main)

Run it from the command line:

.. code-block:: bash

    python3 open_browser_tab.py https://iterm2.com/

:Download:`Download<open_browser_tab.its>`

---

### Api Library Python Iterm2 Docs Examples Random Color (`api/library/python/iterm2/docs/examples/random_color.rst`)

:orphan:

.. _random_color_example:

Random Color Preset
===================

The script demonstrates running some code when a new session is created. It picks a random color preset and changes the session's colors (without changing the underlying profile).

.. code-block:: python

    #!/usr/bin/env python3

    import iterm2
    import random

    async def SetPresetInSession(connection, session, preset_name):
        preset = await iterm2.ColorPreset.async_get(connection, preset_name)
        if not preset:
            return
        profile = await session.async_get_profile()
        if not profile:
            return
        await profile.async_set_color_preset(preset)

    async def main(connection):
        app = await iterm2.async_get_app(connection)
        color_preset_names = await iterm2.ColorPreset.async_get_list(connection)
        async with iterm2.NewSessionMonitor(connection) as mon:
            while True:
                session_id = await mon.async_get()
                session = app.get_session_by_id(session_id)
                if session:
                    await SetPresetInSession(connection, session, random.choice(color_preset_names))

    iterm2.run_forever(main)

:Download:`Download<random_color.its>`

---

### Api Library Python Iterm2 Docs Examples Resizeall (`api/library/python/iterm2/docs/examples/resizeall.rst`)

:orphan:

.. _resizeall_example:

Resize Font in All Sessions in Window
=====================================

This script registers a function that resizes the font of all sessions in a window. To use it, place it in `~/Library/Application Support/iTerm2/Scripts/AutoLaunch`. Then restart iTerm2 or launch it manually. Then add keybindings with the action *Invoke Script Function…* and use a command of `change_font_size(session_id:id,delta:1)` for the keystroke that will make the font bigger and `change_font_size(session_id:id,delta:-1)` for the keystroke that will make the font smaller.

.. code-block:: python

    #!/usr/bin/env python3.7

    import asyncio
    import iterm2
    import re

    async def main(connection):
        app = await iterm2.async_get_app(connection)
        # This regex splits the font into its name and size. Fonts always end with
        # their size in points, preceded by a space.
        r = re.compile(r'^(.* )(\d*)$')

        async def change_font_size_session(session, delta):
            """Change the size of the font in a session by `delta` points."""
            # Get the session's profile because we need to know its font.
            profile = await session.async_get_profile()

            # Extract the name and point size of the font using a regex.
            font = profile.normal_font
            match = r.search(font)
            if not match:
                return
            groups = match.groups()
            name = groups[0]
            size = int(groups[1])

            # Prepare an update to the profile that increases the font size
            # by 6 points.
            replacement = name + str(size + delta)
            change = iterm2.LocalWriteOnlyProfile()
            change.set_normal_font(replacement)

            # Update the session's copy of its profile without updating the
            # underlying profile.
            await session.async_set_profile_properties(change)

        @iterm2.RPC
        async def change_font_size(session_id, delta):
            """Change the font size of all sessions in the window containing the
            session whose ID is `session_id` by `delta` points."""
            session = app.get_session_by_id(session_id)
            if not session:
                return
            tasks = []
            for tab in session.tab.window.tabs:
                for s in tab.sessions:
                    tasks.append(change_font_size_session(s, delta))
            await asyncio.gather(*tasks)
        await change_font_size.async_register(connection)

    iterm2.run_forever(main)


:Download:`Download<resizeall.its>`

---

### Api Library Python Iterm2 Docs Examples Runcommand (`api/library/python/iterm2/docs/examples/runcommand.rst`)

:orphan:

.. _runcommand_example:

Run a Command and Return its Output
===================================

This script runs a command and prints its output to stdout.

.. code-block:: python

    #!/usr/bin/env python3

    import asyncio
    import iterm2

    async def wait_for_prompt(connection, my_session):
	"""Block until the running command terminates."""
	modes = [iterm2.PromptMonitor.Mode.COMMAND_END]
	async with iterm2.PromptMonitor(connection, my_session.session_id, modes) as prompt_monitor:
	    while True:
		type, value = await prompt_monitor.async_get()
		if type == iterm2.PromptMonitor.Mode.COMMAND_END:
		    return

    async def string_in_lines(my_session, start_y, end_y):
	"""Returns a string with the content in a range of lines."""
	contents = await my_session.async_get_contents(start_y, end_y - start_y)
	result = ""
	for line in contents:
	    result += line.string
	    if line.hard_eol:
		result += "\n"
	return result

    async def run_command(connection, my_session, command):
	"""Run a command and return its output. Requires shell integration."""
	# Atomically get the last prompt, send a command, and begin watching for the end of the command.
	async with iterm2.Transaction(connection):
	    prompt = await iterm2.async_get_last_prompt(connection, my_session.session_id)
	    await my_session.async_send_text(command + "\r")
	    task = asyncio.create_task(wait_for_prompt(connection, my_session))

	# Wait for the command to end.
	await task

	# Re-fetch the prompt for the command we sent to get the current output range.
	async with iterm2.Transaction(connection):
	    prompt = await iterm2.async_get_prompt_by_id(connection, my_session.session_id, prompt.unique_id)
	    range = prompt.output_range
	    start_y = range.start.y
	    end_y = range.end.y

	    # Fetch the content in that range and return it
	    content = await string_in_lines(my_session, start_y, end_y)
	return content

    async def main(connection):
	"""Demonstrate how to use run_command"""
	app = await iterm2.async_get_app(connection)
	if app.current_terminal_window:
	    my_session = app.current_terminal_window.current_tab.current_session
	    print(await run_command(connection, my_session, "seq 150"))

    iterm2.run_until_complete(main)

:Download:`Download<runcommand.its>`

---

### Api Library Python Iterm2 Docs Examples Set Title Forever (`api/library/python/iterm2/docs/examples/set_title_forever.rst`)

:orphan:

.. _set_title_example:

Launch iTerm2 and Set Session Title
===================================

This script demonstrates two concepts:

1. Launching iTerm2 using PyObjC and running the script only
   after it is launched.
2. Setting a session's name.

Further, it demonstrates how to change a profile setting so
the session title can't be later changed by a control
sequence.

Launching the app is useful when the script is run from the
command line rather than from within iTerm2. To run this
script from the command line you'll need to install its
dependencies first:

.. code-block:: bash

    brew install python3
    pip3 install iterm2
    pip3 install pyobjc

Here's the code:

.. code-block:: python

    #!/usr/bin/env python3

    import iterm2
    import AppKit

    # Launch the app
    AppKit.NSWorkspace.sharedWorkspace().launchApplication_("iTerm2")

    async def main(connection):
        app = await iterm2.async_get_app(connection)

        # Foreground the app
        await app.async_activate()

        # Create a new tab or window
        myterm = app.current_terminal_window
        if not myterm:
            myterm = await iterm2.Window.async_create(connection)
        else:
            await myterm.async_create_tab()
        await myterm.async_activate()

        # Update the name and disable future updates by
        # control sequences.
        #
        # Changing the name this way is equivalent to
        # editing the Session Name field in
        # Session>Edit Session.
        session = myterm.current_tab.current_session
        update = iterm2.LocalWriteOnlyProfile()
        update.set_allow_title_setting(False)
        update.set_name("This is my customized session name")
        await session.async_set_profile_properties(update)

    # Passing True for the second parameter means keep trying to
    # connect until the app launches.
    iterm2.run_until_complete(main, True)


Note that if you download and install the package below it will
install the needed dependencies for running this script from
within iTerm2 but your system Python configuration will not
be modified. You still need to follow the steps above to
install Python 3, the iterm2 Python module, and PyObjC if
you plan to run this from the command line.

:Download:`Download<set_title_forever.its>`

---

--- METRICS ---
- Files Extracted: 101
- Estimated Token Budget: ~207669 tokens
- Canonical Reference: https://codewiki.google/github.com/gnachman/iTerm2
