2026-10-25: Pace nested SSH Integration connections and add a flow control monitor Combines these commits, oldest first: - Keep each nested framer's controlled frames, with their carrier's send time - Run flow control in nested SSH Integration conductors - Never leave a framer in flow control with no controller - Measure each hop's delay under nested SSH Integration - Quit the framer with a framer command when its shell exits - Recover nested SSH Integration connections deliberately - Test flow control over two hops of SSH Integration live - Drop the release note for the exit hang, which never shipped - Drop the nested recovery release note - Measure the delivery rate over at least a quarter second - Skip replies to commands sent before an SSH Integration reset - End nested SSH Integration recovery with one boundary - Test restarting iTerm2 with a nested SSH Integration connection - Restore SSH Integration sessions saved without the framer's version - Let the restart test compare with a release and restore its sessions - Add a monitor window for SSH Integration flow control - Show the least delay of small and large frames in the flow monitor - Measure queueing delay against a baseline that allows for frame size - List flow control's recent events in its monitor window - Show receive rate and round trips in the flow monitor without flow control - Take back credit granted for a path that has since slowed - Add tools to measure flow control over a throttled path 2026-10-25: Fix tmux workgroups, remote git state and grants; load shell integration inside tmux Combines these commits, oldest first: - Never offer a workgroup's own peers, and let closing an offer do nothing - Let only one lock driver at a time work on a tmux connection - Let a tmux workgroup member that arrives after adoption join it - Make tmux workgroup members end and restart as local ones do - Don't warn about short-lived sessions that were tmux clients - Fix gaps in how tmux workgroup members end and wait on the lock - Check a host for Claude Code's hook once the integration is in use - Read remote git state once a restored connection resynchronizes - Read git state on hosts with git older than 2.28 - Let a restored SSH connection's host control iTerm2 again - Keep each Always Allow This Host grant in its own file on the host - Extract new strings into the string catalog - Drop release notes about an advanced setting and a new feature's fix - Define it2 in xonsh shell integration again - Give each button of a warning its own keyboard shortcut - Read a merge or rebase on hosts with git older than 2.13 - Bring a tmux workgroup's leader back when the member in view is closed - Fix gaps in installing Claude Code's hook on a remote host - Update shell integration for the xonsh it2 fix - Make it2git count changes as libgit2 does - Read files added with intent to add as libgit2 does - Update shell integration for it2git's libgit2-matching counts - Offer to update a customized Code Review prompt, with consent - Open the clippings panel when a tmux review adds a clipping - Say a restarted tmux member ended again, however soon it does - Give a tmux pane on an SSH Integration host that host - Infer a tmux pane's host only when tmux runs on the connected host - Load shell integration inside tmux, and write each sequence whole - Test tcsh's OSC 7 through the alias that now writes it whole - Update shell integration to load inside tmux - Treat TERM=screen-256color as tmux, and run the xonsh check off the caller's stdin 2026-10-25: Pace output from hosts reached through SSH Integration Combines these commits, oldest first: - Add the flow controller for framer output - Negotiate flow control with the framer and carry out its commands - framer: time the safety valve from when output starts waiting - Turn on flow control for SSH Integration - Keep restored workgroup peers on their leader's host - Run remote workgroup peers as panes of the leader's framer - Close a workgroup peer's tab when the workgroup ends with it in view - Turn flow control back on after restoring a conductor - Finish SSH recovery without waiting for the host to print - Reattach workgroup peers to their framer panes after recovery or restart - Offer to open or stop workgroup panes left without a session - Offer to open or stop tmux panes hidden in workgroup parked windows - Close only the tmux windows that asked when the answer is Always - Put an announcement's buttons below its text in narrow panes - Give each flow of a framer connection its own credit - Give the framer credit per flow when iTerm2 asks for version 2 - Give workgroup panes and it2 their own flow control credit - Count a batch of tokens a background session finishes while yielding - Count every byte a workgroup pane's session consumes - Test that a flooding or stalled pane holds back only itself - Hang up on a workgroup pane the framer is told to kill - Fix ways a workgroup pane or its flow could hang, leak or over-grant 2026-10-25: Add a framer test suite, fix framer bugs, and add framer flow control Combines these commits, oldest first: - framer: read program output 4 KB at a time - Run workgroup peer programs through the leader's framer - framer: flow control, version 1 - framer: send the resize ping from the event loop - Add a test suite for framer.py - framer: stop using str.removeprefix - framer tests: make the ^C tests independent of timing and launch - framer: reply with a failure when a handler raises - framer: fail a command that does not decode instead of exiting - framer: frame each line of a poll reply - framer: signal everything a run command started - framer: keep the pty's slave open until a program's output is read - framer tests: fix two tests that failed under load on Linux - framer: don't let a file search keep the framer from exiting - Don't let a long framer command end early - Send framer's deregister command by its right name - framer: report why a file create failed - framer: report permission denied as status 1 - framer: return no more file name suggestions than asked for - framer: delete a symlink to a directory instead of failing - framer: read environment variables without mangling them - framer: pass file paths' bytes through unchanged - framer: run eval scripts byte for byte - framer: treat signaling an exited program as done - Read the framer's replies as UTF-8 2026-10-25: Workgroups in tmux Integration Combines these commits, oldest first: - Workgroups in tmux: shared state model - Workgroups in tmux: encode state as prefixed hex - Workgroups in tmux: store the workgroup's shape, not its definition - Workgroups in tmux: guarded commands and a per-session lock - Stop setting ITERM_WORKGROUP_ID in workgroup members' environment - Workgroups in tmux: commands to create, restart and tag members - Workgroups in tmux: enter a workgroup in a tmux Integration tab - Workgroups in tmux: adopt the workgroups on a server when attaching - Workgroups in tmux: park each hidden peer in a window of its own - Workgroups in tmux: create members atomically and recover from interrupted removals - Workgroups in tmux: create peers under the lock, with crash tests - Workgroups in tmux: end-to-end harness - Workgroups in tmux: buried sessions for parked panes - Workgroups in tmux: peers are tmux panes, and switching works - Workgroups in tmux: a peer switched in by another client keeps its layout - Workgroups in tmux: code review, diff and Reload for tmux members - Workgroups in tmux: pin parked peers again when their slot changes size - Workgroups in tmux: wake on the lock, and say when waiting on another client - Workgroups in tmux: replace a member's pane instead of respawning it before tmux 3.8 - Rebuild it2 from the sources merged onto upstream - Workgroups in tmux: repair what a crash or a hand edit leaves behind - Gate the tmux end-to-end and ssh live harnesses on ITERM2_ONSCREEN_TESTS - Don't crash selecting a tmux window after detaching - Workgroups in tmux: peer groups hosted by a split or tab - Workgroups in tmux: clean up the server when the leader exits - Workgroups in tmux: remove the members when the leader's pane is gone - Don't warn about short-lived tmux panes - Workgroups in tmux: give a peer swapped into view the window's scroll bar - Workgroups in tmux: test that a peer's own split is neither expected nor repaired - Workgroups in tmux: manual test plan for two Macs on one server - Stop session-heavy harnesses from losing sessions to ASan between fork and exec - Don't probe the local network when hosting unit tests - Record no ASan allocation stacks in any test run 2026-10-25: Bring jobs, git status, it2 and workgroup peers to hosts reached through SSH Integration Combines these commits, oldest first: - Phase 5: see the jobs in a tmux pane on a host reached through SSH Integration - Show a remote tmux pane's processes in the Jobs tool - Release notes for seeing jobs in remote tmux Integration panes - it2ssh: run a remote command exactly as ssh would - Evaluate a profile's command line before wrapping it for the shell - Release notes for the it2ssh and profile command quoting fixes - A command run with it2ssh gets it2, like an interactive shell does - Phase 6, part 1: workgroup peers of a remote leader run on its host - Workgroup peers on a remote host: find the leader's directory again at launch, and log it - Phase 6, part 2: a code review peer on a remote host gets its input files there - Don't swap workgroup peers in a tmux Integration tab - A command run with it2ssh finds it2 on PATH - Git status for a repository on a host reached through SSH Integration - A tmux profile created after installing the Claude Code triggers gets them - The it2 wrapper runs iTerm2's native it2 locally, never another it2 - it2 over SSH Integration without shell-integration injection - Install Shell Integration & Utilities installs iTerm2's it2 - Take the it2 wrapper from the shell-integration submodule - Follow the v2 installer's it2 URLs in its test - Drop the release note about the installers installing it2 - Tell Claude Code users when a host's shell integration is too old for it2 - Warn about old shell integration when the hook is found, and retry - Stop recording remote hosts on disk for the Claude Code integration - Show the it2 authorization prompt where the user is looking - Code review tells Claude Code which it2 to run, by path - Keep remote eval files out of /tmp; log the it2 socket's /tmp fallback - Bump shell-integration submodule: remove the stale buildssh script - Distinct conductor boundaries per command - Bump shell-integration submodule: conductor boundary and eval without `local` - Framer lists processes with ps -A, not ps -e - it2 decodes the JSON values iTerm2's API returns - Set IT2_SOCK only when the framer will bind it - it2ssh loads shell integration by default; SSH profiles keep their setting 2026-10-25: Install the Claude Code integration on hosts reached through SSH Integration Combines these commits, oldest first: - Phase 4a: per-host Claude Code integration state - Drop ClaudeCodeRemoteHostState.host(for:) - Phase 4b: find CLAUDE_CONFIG_DIR on a host reached through SSH Integration - Phase 4c: install, check and remove the Claude Code hook over SSH Integration - Remote hook install and uninstall take the grant decision explicitly - Phase 4d, part 1: install the Claude Code hook on a remote host from the installer - A Claude Code hook on a remote host reports to the session it arrived on - Offer the remote Claude Code install from a tmux pane on a remote host - Install Claude Code on a remote host from the main menu items, not Shell > ssh - Phase 4d, part 3: Uninstall Claude Code Integration also cleans remote hosts - Phase 4d, part 2: the health monitor and Try It Now check the session's host - Check a remote host's Claude Code hook once per connection, and log why not - Release notes for installing the Claude Code integration on remote hosts 2026-10-25: Run it2 over SSH Integration Combines these commits, oldest first: - Phase 3a: ship it2 to remote hosts as a real executable on PATH - Fix ssh integration broken by setting PATH through the conductor - Phase 3b: carry a remote command's stdin and environment over the wire - Phase 3c: a remote it2 with no target addresses the session it arrived on - Default to the session the caller is actually in, not the focused one - Phase 3d, part 1: an agent hook never blocks on it2 authorization - Phase 3d, part 2: "Always Allow This Host" for it2 over ssh integration - Phase 3d, part 3: manage "Always Allow This Host" grants - Fix the always-allowed hosts sheet's row alignment and its button's enabled state - Phase 3a, finished: it2 versions its HELLO and runs the copy its connection uploaded - Pin that nested ssh integration uses the innermost conductor for it2 - Add a live harness for it2 over ssh integration against localhost - Release notes for it2 over SSH Integration (Phase 3) - Live harness: check that the shell itself receives IT2_SOCK and IT2_NONCE 2026-10-25: Fold cc-status into it2 handle-agent-hook and resolve Claude Code's config directory Combines these commits, oldest first: - Fold cc-status into it2 handle-agent-hook and fix two tmux pane job-detection defects - Add make reset-suite to wipe a dev suite's settings while keeping SetIT2AppPath and AiChatRawWireLogging on - Resolve CLAUDE_CONFIG_DIR for the session that opened the installer and ask when its profile does not use the login shell - Ask which CLAUDE_CONFIG_DIR to install into, not which shell to ask - Judge the installer's hook step by the resolved directory, and collapse duplicate hook entries - Point the shell-integration submodule at the rebased it2 client-record work - Find a tmux pane on any controller attached to its server, not just the caller's - Stop one iTerm2 instance from answering for another's Claude Code install - Route a pane's it2 call to the connection that can actually place it 2026-10-25: Extract and translate the strings added in the QA fix pass Adds the catalog entries for eight strings introduced by recent fixes (SSH Integration recovery announcement and Disconnect button, the Apple Intelligence lockout error, SSH upload name-check and file-operation errors, and two shell integration installer messages) with German, Japanese, Brazilian Portuguese, Russian, and Simplified Chinese translations. 2026-10-25: Don't type an abandoned SSH connection's commands into the local shell QA finding F-554. While the remote streams output, ssh is busy writing it and leaves the conductor's commands (such as the autopoll request sent after each autopoll report) unread in the tty. When it2ssh was killed and the connection abandoned, those commands were left for the local shell, which read YXV0b3BvbGw= as a command line. When a connection is abandoned as soon as its it2ssh is found dead, and the tty is still in ssh's raw mode with nothing new in the foreground, the session now drops its unwritten output to the pty and discards the tty's pending input. Disconnect's forced unhook seconds later doesn't, since the input may be the user's by then. An abandoned conductor also never writes again, so a report handled after the abandonment can't send anything, nor can a nested connection write through its still-connected parent. 2026-10-25: Keep drawing the shell's output after abandoning a streaming SSH connection QA finding F-553. Restoring the tty modes ssh changed set them through the pty's master. On macOS that discards the output waiting to be read and doesn't wake a process waiting for that output to drain. When it2ssh was killed while the remote streamed output, zsh restored its own modes with TCSADRAIN as the killed job ended and slept there, so nothing it printed reached the session until something else wrote to the tty. The modes are now set through the pty's slave, which keeps the pending output and wakes the shell. This came from 8a840cd7e, not from deac939d3. The dead connection could also leave an unterminated OSC 134 frame header behind (its ssh died while writing it), which would have swallowed the local shell's output as the rest of the OSC. A framer notification header (%output, %autopoll, %end, and the like) parsed outside the hook now ends at a line break or at the start of another escape sequence, neither of which the framer puts in one, and those are then parsed normally. Other OSC 134 payloads, such as command replies, are unchanged. 2026-10-25: Exit installer helpers that start after Cancel, and detect leftover ones Canceling an Internet-Free install step after it typed the helper launch but before the helper sh prompted (as when the user's shell is slow to start a command) typed nothing, so the session was left in two helper sh at a >> prompt. Trying again ran discovery in the helper, whose parent is sh (bash on macOS), and reported bash. After a cancel while the helpers launch, the installer now waits up to 30 seconds for their prompt, asks the shell that printed it how many helpers deep it is (IT2_INSTALLER_HELPER, which each helper sets one higher than its parent), and types the exits only if that matches what was launched. Otherwise, or if no prompt comes in time, it types nothing more. A new installer window for the same session waits for the previous one first. Discovery reports when it runs inside a helper shell and asks the user to exit it, instead of detecting a shell. QA finding F-552. 2026-10-25: Point localization tests at the tracked string catalog Four test classes read sources/Localizable.xcstrings, a gitignored copy that only the iTerm2 scheme's pre-action creates and that Xcode's localization sync rewrites, so in a fresh worktree they failed with file not found and otherwise checked a modified copy. They now read xcstrings/Localizable.xcstrings. Tangential finding T-108 from the QA fix pass. 2026-10-25: Highlight attribute values in View Source The highlighter looked for values in " or ', but innerHTML doesn't escape quotes in text, so no value ever matched. It now matches plain double- and single-quoted values. The matched text comes from the tag's attribute text, which can't contain an unescaped < or > or inserted markup, so wrapping it in a span can't create elements. Tangential finding T-95 from the QA fix pass. 2026-10-25: Escape the composer's ghost text for file completions didFindCompletions picked the inline suggestion from the raw file completions rather than the shell-escaped ones, so with AI off an explicitly requested completion for a name with a space showed unescaped ghost text. Tangential finding T-85 from the QA fix pass. 2026-10-25: Draw the shell integration installer's Learn more links in the label font The link runs had no font attribute, so they drew in Helvetica 12 next to the system-font text. Tangential finding T-94 from the QA fix pass. 2026-10-25: Percent-encode + in a URL template's query value Search the Web for Selection put the selection into the search URL with NSURLQueryItem, which leaves + unescaped, and servers decode + in a query as a space, so c++ was searched for as c . The query is now edited in its percent-encoded form: the value is encoded with +, &, =, # and ; escaped, and the rest of the template's query keeps its own encoding (before, a %2B elsewhere in it became +). Tangential finding T-102 from the QA fix pass. 2026-10-25: Don't unpack the utilities elsewhere when cd to ~/.iterm2 fails The Internet-Free installer's utilities step typed cd ~/.iterm2; echo done, which reported done even when cd failed (as when ~/.iterm2 is a file), so the archive would be unpacked in the helper's current directory. Now the cd answer says whether it worked, the receive command is typed only after it did, and on failure the helpers exit and the step reports that the utilities were not installed. Tangential finding T-130 from the QA fix pass. 2026-10-25: Read a signing certificate's expiration from its Not After date SIGCertificate's expirationDate asked for the invalidity date extension rather than the validity period's Not After date. It only worked because Security synthesizes an Expires entry for that OID. It now reads kSecOIDX509V1ValidityNotAfter, converting its seconds-since-reference-date value, and checks the types it gets back. A failed trust evaluation with an underlying error now uses SIGErrorCodeTrustFailed instead of SIGErrorCodeTrust, whose description is for failing to create a trust object. Tangential finding T-97 from the QA fix pass. 2026-10-25: Ignore malformed trigger parameters from untrusted dictionaries A Dynamic Profile trigger whose parameter was an array, dictionary, or null crashed Bounce Dock Icon and Set Mark triggers, which call -intValue on it, and Invoke Script Function passed it on as a string. Only string and number parameters are now accepted. A number given to a trigger that takes text is used as that number's text. Tangential finding T-40 from the QA fix pass. 2026-10-25: Honor the generative AI lockout for Apple Intelligence Apple Intelligence runs on-device, so its requests never reach iTermAIClient.request, which refuses remote LLM requests when an administrator sets GenerativeAIAllowed to NO. AI tab titles, AI chat with the Apple Intelligence model, and the on-device command safety check kept running under the lockout. AppleIntelligenceRunner now refuses complete, generate, and generateTabTitle with a disallowedByAdministrator error before creating a session or counting tokens. AITermController fails an Apple Intelligence request up front with the lockout message, which the safety check treats as unsafe (manual approval). AI tab titles are off under the lockout, so no screen is captured and an existing AI title is cleared. Tangential finding T-28 from the QA fix pass. 2026-10-25: Don't overwrite a remote file when an upload's name check fails When picking a remote name for an SSH integration upload, any stat error (a permission error, a dropped connection) was read as "the name is free", so the upload could replace an existing file. Now only a definite ENOENT from the framer counts as free; any other failure stops the upload with an error before anything is written under the final name. File operations now report non-ENOENT framer failures as a distinct error instead of "File not found". Tangential finding T-41 from the QA fix pass. 2026-10-25: Cancel an scp password request when the password manager closes Closing the password manager without choosing a password dropped the pending completion without calling it, so the scp transfer thread waited forever. Now the completion is called once with nil (cancel), it can never run twice, and the copied block is released (the file is MRC). Tangential finding T-69 from the QA fix pass. 2026-10-25: Expect the v2 installer URL in the online installer tests ec52a96ee switched the typed command to the v2 installer URL, but the test's expected curl command still used the old URL, so the fake terminal never replied and all three tests failed. QA finding F-540. 2026-10-25: Return to the user's shell after canceling an Internet-Free install step Cancel left the terminal inside the installer's helper sh at a >> prompt: during the utilities archive no exits were typed, and the script step launched a second, nested pair of helpers, so its exits closed only one pair. Send Again while a step was busy typed the launch lines again inside the running helpers, so each retry left two more shells. The installer now tracks the helper shells from their output (their prompt, a step's answers, or an echo that includes the helper's PS1) and types exit only when it knows how many are running and that the innermost reads commands. Cancel exits idle helpers right away, or once an in-progress here-document or the ended archive read loop is done; before the launch is confirmed it types nothing. Send Again reuses running helpers instead of starting more, waiting for them if they are busy. Discovery exits leftover helpers first. The script is written without the nested helpers, in one here-document. QA findings F-543 and F-544. 2026-10-25: Keep repairing tty modes until zsh records good ones QA finding F-541. zsh records the tty modes when a foreground job ends and applies them when its line editor returns, before running the command line. After a killed it2ssh it had recorded ssh's raw modes, but the repairer disarmed at the OSC 133 D for the killed command, which arrives right after the repair at the prompt, so zsh reapplied the raw modes before every later command. Without shell integration there is no OSC 133 C at all, so the one repair at the prompt never stuck. A command's end now disarms the repairer only if that command started with good modes that the repairer had not set itself since the previous command ended (or, for a fork, since the previous command started). Until then the shell's recorded modes may still be ssh's. While armed, the repairer also watches the shell that ran it2ssh for forks with a process dispatch source. zsh forks to run an external command after applying its recorded modes, so if the tty then has ssh's modes with ICANON and ECHO set, the repairer fixes them and zsh records the fixed modes when the command exits. This works without shell integration. Forks never disarm; the shell's exit does. All existing safety rules still apply: the pty must still be the session's, a tty with ICANON or ECHO off is never touched, and modes that changed since they were read are left alone. A reparented it2ssh's parent (launchd) is never watched. 2026-10-25: Restore the tty modes ssh changed when recovering from a killed it2ssh QA finding F-460. When it2ssh is killed, its ssh client can't restore the tty modes it made raw (it is no longer in the foreground process group and is killed anyway), so after recovery the session had no signals, output processing, or flow control. iTerm2 now records the tty's modes when it2ssh fetches its token, before ssh starts. When the outermost connection is abandoned or force-disconnected and its watched it2ssh is known to have exited, iTerm2 restores the modes ssh cleared through its end of the pty, never writing to the shell. It does nothing if another connection is active or has started on the tty since, if it2ssh ran on another tty (e.g., inside tmux), or if it2ssh ran without job control. It changes the modes right away only if the tty's foreground process group is the shell that ran it2ssh, or it2ssh's job once that job has no members left; otherwise it only arms. At the prompt it restores only what a line editor leaves alone. zsh records the modes when a job dies and reapplies them before each command, so the repairer stays armed: when a command starts (OSC 133 C) with the tty in ssh's mode but ICANON and ECHO set, it fixes the tty again so zsh records good modes when the command exits, and it disarms once a command finishes with good modes or the session's shell is replaced. A program that makes the tty raw itself clears ICANON and ECHO and is never touched, and modes that change between reading and writing are left alone. Before every change it checks that the fd is still the master of the session's pty, so a closed and reused descriptor is never touched. Without a snapshot it uses the modes iTerm2 gives new sessions. 2026-10-25: Retain the table of slowly sent pastes PTYSession is manual retain/release, and 8dcb73f6d stored an autoreleased NSHashTable in an ivar without retaining it. The table was freed when the autorelease pool drained, so the next sendTextSlowly: (the Internet-Free installer's second command) messaged a dead object and crashed. QA finding F-542. 2026-10-25: Stop typing the utilities archive when the installer is canceled Canceling the Internet-Free install while it typed the utilities archive closed the window, but the archive, which takes a minute or more to type, kept being typed until it was done. Cancel (and Send Again while busy) now stops that paste through a new iTermPasteHelper call that aborts only the pastes it is given, so the user's own pastes are not touched. Then it types the line that ends sh's read loop, which the archive would have ended with. Otherwise sh would keep taking whatever is typed next into its file, including the commands of a retry. sh now lists the whole archive before extracting, which checks gzip's CRC, so a partial archive unpacks nothing rather than overwriting utilities with partial files. The here-document of a shell integration script is still left to finish, since ending one cut short would write a partial script. QA finding F-463. 2026-10-25: Don't type the utilities archive into sh when it can't be received If the helper sh could not create the file the utilities archive goes into (for example because ~/.iterm2 is not writable), the read loop's redirection failed and the loop never ran, yet sh still printed that it was receiving. Decoding the missing file failed, but tar given no input succeeded, so sh reported that the utilities were installed. iTerm2 then typed the whole archive into sh's prompt as commands, and the installer said the utility scripts were installed. The redirection now applies to the group that prints the receiving marker, so sh prints it only once the file is open, and prints a distinct marker otherwise. iTerm2 types the archive only after the receiving marker; on the other one it leaves the helper shell and shows that the utilities were not installed. Success now requires that the archive decoded, that tar extracted it, and that a file from it exists, each checked separately since POSIX sh has no pipefail. The temporary files are removed either way. QA finding F-462. 2026-10-25: Check that the Internet-Free utilities install unpacked The utilities archive, hundreds of base64 lines, was typed into a here-document that sh read through its line editor. On a busy system lines got lost or spliced, tar failed, and the installer reported success anyway since it never looked at tar's result. The helper sh now reads the archive with a read loop (no line editor, and the tty stays in canonical mode, where a full input buffer makes the writer wait instead of dropping input) into a file, decodes it into tar, and prints whether tar succeeded. The archive is typed only after sh says the loop is reading, so none of it can run as commands. On failure the step shows that the utility scripts were not installed and offers to try again instead of moving on. QA finding F-352. 2026-10-25: Never recover or hook from an abandoned SSH connection's queued output QA finding F-459. When it2ssh was killed while the remote streamed output, framer output that was already parsed, executed, or waiting on the main thread when the connection was abandoned could still start framer recovery or a conductor, which typed into the local shell and wedged the session. Abandoning or disconnecting a connection now refuses recovery on the main thread at the moment of the decision, so a recovery requested earlier is dropped and the terminal leaves recovery mode. The refusal covers the abandoned connection's depth and every deeper one, so output parsed inside its framing can't recover either. A hook parsed inside an abandoned connection's framing, or carrying an abandoned connection's unique ID, no longer starts a conductor or lifts the refusal; only a new connection's hook does. The basic case (framer output arriving after the forced unhook) was already handled by 7d023c6bb; QA tested a build without it. The new tests cover the queued, mid-frame, and pending side effect cases that it did not. 2026-10-24: Don't type an installer command after Cancel Since the fix for F-353, iTermExpect holds an expectation's original until its willExpect callback runs, so a callback already queued for the main thread runs even if the installer canceled the expectation in the meantime, typing the next command (or the utilities archive) after the user canceled. The installer now ignores callbacks queued before its last cancel. iTermExpect itself still runs them, since the bracketed-paste expectation relies on its callback running after a cancel. Follow-up to QA finding F-353. 2026-10-24: Type every line of the Internet-Free install's bash dotfile step For bash, the dotfile step left the line that loads shell integration out of the here-document, so ~/.profile got only an empty line. The line was lost in iTermExpect. When a wet copy of an expectation is added, it dispatches the original's willExpect callback (which types the text) to the main thread through a weak reference to the original. sh's echo of the here-document line and of the empty line after it answered two expectations in one batch of output, so the second one completed on the main thread before its callback ran. Nothing else held it, so the weak reference was nil and the line was never typed, while the callbacks after it (EOF, fi) still ran. The dispatched block now holds the original strongly. QA finding F-353. 2026-10-24: Ignore an abandoned SSH connection's framer output instead of recovering QA finding F-354. When only it2ssh was killed, its ssh client kept running and its remote conductor kept printing framer output such as an autopoll report (ESC ]134;:%autopoll ESC \). Once iTerm2 had abandoned the connection and removed its hook, that OSC 134 arrived outside any hook and started framer recovery, which typed base64 "recover" and blank lines into the local shell and left a recovery conductor taking the keystrokes. Abandoning a connection (automatically or from the announcement) and Disconnect now refuse framer recovery at that connection's parent depth until a new connection hooks there, so its framer output is ignored and anything else it prints is drawn. The orphaned ssh client is also killed before the conductor is unhooked, so less of its output arrives. 2026-10-24: Kill rather than hang up the orphaned ssh client QA finding F-354. The ssh client a killed it2ssh leaves behind is now sent SIGKILL instead of SIGHUP. Its process group is orphaned, so SIGHUP and SIGTERM made it print "tcsetattr: Input/output error" into the session; SIGKILL prints nothing. All of the identity and safety checks are unchanged. 2026-10-24: Hang up the ssh client a killed it2ssh leaves behind QA finding F-351. When a watched it2ssh starts, the monitor records its single ssh child (pid, start time, and arguments). When the session recovers from the dead it2ssh, either automatically or through Disconnect's forced unhook, it sends SIGHUP to that ssh client so it no longer stays logged in to the remote host on the session's tty. It signals only when the pid, start time, and arguments still match, it2ssh is gone and is no longer ssh's parent, and ssh is not listening on a unix socket (a possible ControlMaster). Records are forgotten when a connection ends normally or aborts. 2026-10-24: Detect a killed it2ssh and recover the SSH Integration session QA finding F-482. When it2ssh runs on this Mac, the secret server records the ancestry of the process that fetched the token, so the conductor can find and watch the it2ssh process. When it exits without sending EndSSH, the session ends its SSH Integration state and removes the conductor hook automatically. When it2ssh can't be watched, a shell prompt mark arriving outside the conductor's framing shows an announcement, once per connection, offering to disconnect. Disconnect can now also remove a nested conductor's hook. 2026-10-24: Focus the status bar search field under focus follows mouse With focus follows mouse including views like Search or Composer, the window's shared status bar is outside every pane, so nothing noticed the pointer over its search field. It now counts as part of the session it shows and tracks pointer movement. QA finding F-483. 2026-10-24: Recover SSH Integration sessions on Disconnect after it2ssh is killed QA finding F-482. When the local it2ssh process is killed, neither the framer's unhook nor it2ssh's EndSSH ever arrives, so the conductor DCS hook swallowed the local shell's output. Disconnect now forces the outermost conductor hook off if it is still present 3 seconds later, and a late EndSSH for a disconnected conductor still releases it2ssh. 2026-10-24: Keep the Internet-Free utilities install from hanging at a > prompt The utilities step typed each command after the answer to the one before it: cd waited for the ok from mkdir, and the here-document line waited for the ok from cd, a line consisting only of ok. When a shell was slow to answer, as with tcsh started from zsh, the ok from cd arrived after the here-document line was typed and landed in the middle of its echo, so no line was ever just ok and the body was never sent. Each command now waits for its own answer, a distinct marker that its echo cannot contain and that is matched anywhere in a row. The here-document line only waits for its echo, since sh reads the body from the tty after it. QA finding F-481. 2026-10-24: Remove blank lines added to the end of the 3.7 release notes 2026-10-24: Drop Tahoe tab bar insets when using Sequoia-style tabs. Issue 12498. With useSequoiaStyleTabs on macOS 26, tabBarInsets still returned the insets sized for the Tahoe pill, leaving padding to the left, right, and below the tabs and making the add-tab button taller than the tabs. Return zero insets instead, matching earlier OS versions. Co-Authored-By: Claude Opus 5.5 2026-10-24: 3.7.6 release notes 2026-10-24: Show a globe on mention chips for browser sessions An @-mention chip for a web browser session showed the terminal icon. The mention picker now reports the icon with its choice, and chips rebuilt from tokens resolve the session to choose it. QA finding F-480. Show a globe on sent mentions of browser sessions A sent message's @-mention of a web browser session showed the terminal icon even though its chip in the message field showed a globe. Message bubbles now choose the icon the same way chips do, and a mention of a session that no longer exists keeps the terminal icon. QA finding F-501. 2026-10-24: Return xonsh to its prompt after the Internet-Free install The Internet-Free shell integration install left xonsh users in the helper sh after writing the script. Two things caused it. The step that writes the script built its nested helper launch through an out parameter, which ARC passes as nil, so that launch started a chain of its own and was typed at the same moment as the outer one. And for xonsh the first command after a launch was typed as soon as xonsh echoed the launch. xonsh's line editor reads typeahead in bulk, like nushell's, so it kept those commands and ran them after the helper exited, starting a helper sh nobody exits. The expectation parameters are now inout so each step is one chain, and for xonsh, as for nushell, the installer waits for the helper's own prompt (not a wrapped row of the echo) before typing into it. QA finding F-436. 2026-10-24: Render a streamed reply's final code block as code A streamed AI reply can end with a space after the closing fence. SwiftyMarkdown treated the whitespace-only last line as a setext heading underline, turning the last code line into a heading, and did not accept a closing fence with trailing whitespace. Normalize fence lines and blank whitespace-only lines outside code blocks before rendering. QA finding F-437. 2026-10-24: Keep Session.preferred_size in step with layout changes in the Python API A Session's preferred_size was set from its grid size when the object was created and never refreshed. The App reuses Session objects across layout changes (update_from copies the new grid size but left the preferred size alone), so after async_split_pane() the original session still preferred its pre-split width. A no-op Tab.async_update_layout() then sent that stale width and the server grew the pane and the window back to it, past the screen edge. The server cannot tell a stale size from a requested one, so this is fixed in the client: preferred_size now follows the session's actual size on every layout refresh, unless the script has set it and async_update_layout() has not applied it yet. QA finding F-500. 2026-10-24: Report -compare-rendering image save failures as errors The rendering comparer ignored whether its three PNGs were written, so run from an unwritable working directory it printed their paths and RESULT: PASS although none of the files existed. Saving and reporting now live in iTermRenderingComparer.saveAndCompare, which reports each failed save as an ERROR line, prints RESULT: ERROR, and returns a nonzero exit status. NSImage saveAsPNGTo: now returns whether it succeeded, and iTermImageComparison reports whether the diff image was written. Since NSApp.terminate always exits with status 0, a nonzero status is applied from an atexit handler after AppKit finishes terminating. The comparer's other ERROR paths exit nonzero too. QA finding F-435. 2026-10-24: Stop counting the next prompt in Command Info's output size The output coord range for a command mark is half-open: it ends at column 0 of the next prompt's line. PTYTextView converted it to a line range inclusively, so the Output row counted the next prompt and any command typed at it ("echo HELLO" showed 39 bytes in 2 lines, "sleep 2" showed the 27-byte prompt). The conversion now lives in -[VT100Screen lineRangeOfOutputForCommandMark:] and excludes that line. Also fix the size of output in scrollback: numberOfCellsUsedInRange: counted to the end of history for a range wholly in history, and LineBuffer's numberOfCellsUsedInWrappedLineRange:width: dropped the range's last line. QA finding F-419. Stop counting zsh's PROMPT_SP padding in Command Info's output size When output does not end in a newline, zsh's PROMPT_SP writes its inverse eol mark (% or #) after it and pads with spaces until the line wraps, so the next prompt starts on a new line. The output's last line then held the mark and a line's worth of spaces, and "printf abc" in a 195-column window showed 196 bytes. The OSC 133 D sequence arrives from precmd, after PROMPT_SP has already written the padding, so the cursor position at D does not mark the end of the output. Instead, when the output's last line soft-wraps into the next prompt's line and ends in an inverse % or # followed by spaces, those cells are not counted. Spaces before the mark belong to the output and are kept. If the mark is missing (an empty PROMPT_EOL_MARK) nothing is trimmed, so real output is never undercounted. QA finding F-350. 2026-10-24: Translate Type @ as a verb in the Chinese Cockpit hints The Simplified Chinese Cockpit composer hint read @ (Input type: @ choose sessions), treating Type as the noun . The Type @ to pick a session status had the same mistake ( @ ). Use the verb in both: @ and @ . QA finding F-512. 2026-10-24: Shorten the German Undo timeout label in profile Session settings The German label above the Undo seconds field in Settings > Profiles > Session measured 561pt in the system font, wider than its 551pt clipping frame, so the trailing n: was cut off. Reword it as Sekunden, in denen Rckgngig eine geschlossene Sitzung wiederherstellen kann: (511pt), and add a test that every languages label fits the frame. QA finding F-511. 2026-10-24: Make Find in Settings focus its search field With the Settings window key over a terminal window, Edit > Find > Find opened the find bar in the terminal behind Settings. Settings is a panel, so the terminal stays the main window, and nothing in Settings' responder chain handled performFindPanelAction:, so AppKit fell through to the main window's chain. PreferencePanel now handles Find by focusing its search field, and disables the other find panel actions. Settings' field editor forwards Find to it, since otherwise the field editor claims the action and disables it. QA finding F-418. 2026-10-24: Size the Session Status priority popover to its translations The Session Status gear popover was a fixed 280 points wide with a fixed 44 point Notify column, so in German the column header read Bena and the Arbeitsgruppenstatus zusammenfhren checkbox was clipped. Measure the localized checkbox titles, Notify header and instructions, and widen the popover, the column, or the instructions as needed. English keeps its original size. QA finding F-496. 2026-10-24: Dim the FFM subviews option on first show when FFM is off In Settings > Pointer, even to views like Search or Composer was enabled when the pane first appeared with Focus Follows Mouse off. Its shouldBeEnabled block was assigned after defineControl:, which had already updated the enabled state while no rule existed, and nothing updated it again until Focus Follows Mouse changed. Update the enabled state once the rule is in place. QA finding F-417. 2026-10-24: Decode HTML tab title entities even when there is no tag With HTML tab titles on, newAttributedStringWithHTML: treated a title as HTML only if it contained , or . A title that escaped only & or < (as a program must to show a literal ) was shown with raw entities, while the same text next to a bold word was decoded. Also take the HTML path when the title contains < or &, the two entities the parser decodes. Python API status bar components, which always go through this parser, now decode those entities without a tag too, consistent with how they already did with one. QA finding F-348. 2026-10-24: Give the tab group context menu the window's appearance A tab's context menu takes its appearance from the window (or, in the Minimal theme, from the decoration background color), but the tab group pill's menu had none, so it was drawn in the system appearance when that differed from the window's. Move the appearance choice into a helper and use it for both menus. QA finding F-416. 2026-10-24: Stamp a command mark's start date once for both thread copies Command Info showed Running Time: 0:-1.999 for any quick command. setFirstLineOfCommand: stamped startDate with [NSDate date]. It runs inside a mutateObject block, which is replayed on the main-thread doppelganger when the threads next sync. For a quick command the C (command start), D and next A all arrive before that sync, so the doppelganger's startDate was stamped after its endDate (captured when D arrived), giving a running time of about -1 ms. The formatter then floored that to 0:-1.999. Capture the start date once, outside the mutation block, so the progenitor and the doppelganger agree, for both the shell integration and auto-composer paths. Also clamp negative intervals to zero in formattedHMS. QA findings F-415 and F-315. 2026-10-24: Pick the URL search pane with the logical coordinate urlActionForClickAtCoord: and urlForCopyAtCoord: limit the URL search to the pane between column dividers. Divider columns are found in logical space, but the window was chosen with the visual coordinate under the pointer. On a reordered right-to-left row that touches a divider (a non-integration tmux split, for example), the visual column can be on the other side of the divider, so the search was confined to the wrong pane and the URL was not detected. Now both pass the logical coordinate they already computed. QA finding F-076. Hit-test a reordered URL's visual range in its drawn columns Cmd-hover underlines a URL only when the pointer is inside the URL action's visual range. That range kept the column window of the pane around the URL, which is in logical columns. On a right-to-left row in a non-integration tmux split, the URL can be logically right of the pane divider but drawn left of it, so no cell of the URL was ever inside its own visual range and nothing was underlined. Quick Look, drag and the context menu use the same check. visualWindowedRangeForLogical: now widens the column window to cover the columns the range is drawn in when bidi is on. The context menu's Copy for a URL action built a selection from the visual range, but selections are logical, so on a reordered row it copied the wrong cells (and with the wider window, more of them). It now uses the logical range. QA finding F-456 (follow-up to F-076). Bound every cell of a reordered range when finding its visual range Cmd-hover underlines a URL only when the pointer is inside the URL action's visual range, and otherwise leaves the old underline alone. visualRangeForLogical: built that range from the visual positions of the range's first and last logical cells. A URL that ends in right-to-left text (such as https://example.com/ followed by a Persian letter) is not drawn in one run, so those two cells are not its visual edges and most of the URL fell outside its own visual range. Moving the pointer from another URL straight onto that part kept the other URL underlined. With bidi on, the visual range now spans the visual columns of every cell on its first and last rows. QA finding F-427. Test URL hover over every cell of a URL ending in RTL text in a tmux split QA found that in a non-integration tmux split, Cmd-hover underlined https://example.com/ followed by a Persian letter only over its first few drawn cells. The URL action's visual range was built from the visual positions of its first and last logical cells, which on that row span only the first three drawn cells. 89d9078fb (F-427) already fixed this by bounding every cell of a reordered range. This adds a regression test with QA's rows (t20l.sh in the left pane, t20r.sh in the right) that checks every drawn cell of the URL finds it and falls inside its visual range. It fails without 89d9078fb. QA finding F-495. Drop a redundant nil coalescing in BidiURLDividerTests VT100GridWindowedRangeDescription returns a non-optional String, so the ?? "" in two failure messages only produced compiler warnings. Found while landing QA finding F-495. 2026-10-24: Make each GPU-unavailable reason a complete sentence Help > GPU Renderer Availability built its message by injecting a separately localized reason fragment into GPU rendering is off in the current session because %@. The fragments did not all fit the frame (because This tab has too many split panes, with a capital and no period), and translations could not reorder or reinflect them. Each reason is now its own complete localized sentence, shown as is, with German, Japanese, Brazilian Portuguese, Russian and Simplified Chinese translations. A few English reasons were also reworded to read correctly in the sentence. QA finding F-429. 2026-10-24: Restore a float's place in the z-order on Undo Close Undo Close of a float installed it in front of the other floats. A maximized float fills the tab and is normally kept behind the other floats so they stay visible, so after Undo Close it covered them all. Both the live and the archived Undo Close paths now move the float just behind the backmost float that the closed tab's arrangement had in front of it, which is where it was. If none of those are still in the tab it stays in front, as before. QA finding F-428. 2026-10-24: Show an error instead of crashing on a damaged script archive certificate Importing a .its script archive whose embedded signing certificate was corrupt crashed iTerm2 with "CFRelease() called with NULL". SIGCertificate's initWithData: returned nil when SecCertificateCreateWithData failed, and its dealloc then released the NULL SecCertificateRef. Guard every NULL CF path on the signature verification route: - SIGCertificate dealloc releases only a non-NULL certificate, and initWithSecCertificate: returns nil for NULL instead of retaining it. - certificateInArray: no longer passes a NULL subject to CFEqual. - name initializes its out-parameter so a certificate without a common name does not release stack garbage. - SIGArchiveVerifier fails with a malformed-certificate error when the signing certificate has no extractable public key, and stops when the archive has no certificates instead of continuing with an error set. Verification now fails with SIGErrorCodeInputMalformedCertificate, which the importer shows as an alert. QA finding F-414. Balance Core Foundation ownership in SignedArchive SIGIdentity's initWithSecIdentity: stored the identity without retaining it but released it in dealloc. That only worked because allSigningIdentities leaked the keychain result array that held the identities. The initializer now retains, and the array is released. Also fix leaks in the same classes: - signingCertificate released no reference from SecIdentityCopyCertificate. - longDescription and serialNumber leaked the CFErrorRef on failure. - certificateIsRootWithName: leaked the anchor certificate array. - secCertificateWithName: leaked the keychain result array; it is now copySecCertificateWithName:, returning a retained certificate that issuer releases. - SIGTrust's evaluateTrust: leaked the CFErrorRef, and a failed evaluation without an underlying error reported no error, so the script importer showed "Unknown error". It now reports a trust error. Found while fixing QA finding F-414. 2026-10-24: Clear the unsaved-settings indicator after Save Now With settings loaded from a custom folder and saved manually, the red "!" in the Settings title bar shows that local settings differ from the folder's. UserDefaultsUnsavedController rechecked only on UserDefaults.didChangeNotification, and Save Now writes the folder's plist without changing any local default, so the indicator stayed until the Settings window was reopened. iTermRemotePreferences now posts iTermRemotePreferencesSavedPrefsDidChangeNotification on the main thread whenever its saved copy of the folder's prefs is replaced, and the controller rechecks when it arrives. When saving automatically it only rechecks to clear an indicator that is showing, since an autosave snapshots defaults off the main thread and an edit made since then would otherwise flash the indicator. QA finding F-494. 2026-10-24: Lay out the Minimal title again when only its subtitle changes In the Minimal theme a one-tab window draws its title and the profile's subtitle in place of the tab bar, in a label sized for one line or two. A window from Shell > New Window gets its title before the session's path is known, so the label was laid out for one line. When the subtitle arrived later and the title stayed the same, PseudoTerminal only called -[iTermRootTerminalView setSubtitle:], which updated the label's text but not its frame, so the second line stayed clipped until a resize laid the window out again. setSubtitle: now relays out the window pane decorations when the subtitle changes, as windowTitleDidChangeTo: already does for the title. QA finding F-413. 2026-10-24: Report a dead key's leftover character as the dead key in CSI u With kitty keyboard flags that report all keys as escape codes, a dead key followed by a key it doesn't compose with (German dead ^ then <) makes Cocoa insert the dead key's character and then the second key's text, both while the second key is the current event. Each insertion was encoded from the current event's key code, so the application got the second key's report twice and never saw the ^. The mapper now remembers a dead key press until the next key down. If the first text inserted for that key press is what the dead key types on its own (the key translated by the layout, or its charactersIgnoringModifiers), it is reported as the dead key's press. Later insertions, text inserted outside that key press, and compositions (, or the bare accent typed with space) are encoded as before. QA finding F-346. 2026-10-24: Describe an unfinished AI tool call accurately in the next request When the AI asked to run a command and the user ignored the Allow/Deny prompt and sent a new message, the next request paired that tool call with a stand-in result saying iTerm2 had been restarted and inviting the model to re-issue the call. Nothing had restarted; the user had moved on, so the model was told something false and nudged to rerun a command the user passed over. The stand-in result now says the call did not complete, usually because the user moved on without answering the permission request, possibly because it was interrupted (for example, by a restart). It says the call's effects are unknown and asks the model not to run it again unless the user asks. QA finding F-493. 2026-10-24: Open a popup browser tab's context menu in that tab A browser tab opened by a target=_blank link (including View Source links) showed no context menu. Its web view is built from the opener's configuration, so it shares the opener's user content controller: the context menu script it runs carries the opener's secret and posts to the opener's iTermBrowserManager. That manager opened the menu in its own web view, which sits in a hidden tab, and the script had already suppressed WebKit's default menu. Open the menu in the web view that sent the message instead. Other script message handlers still act on the opener's web view; they are left for a separate change. QA finding F-412. Give a browser tab opened from another its own script handlers A tab opened by a target=_blank link or window.open got a copy of its opener's configuration, including the opener's user content controller. Its scripts carried the opener's secrets and posted to the opener's manager, which acted on the opener's page: selection and copy on select, hovered links, find, autofill, the password manager and named marks. Its URL scheme tasks also went to the opener's manager through a weak reference, so its iterm2-about pages stopped loading once the opener closed. Give such a tab a fresh user content controller and set it up like any other tab's, keeping the inherited data store and URL scheme handlers (WebKit requires the former for a related web view and won't replace the latter). The scheme handler now hands each task to the manager of the web view that asked, and fails it if that web view has none. A manager also ignores any script message from a web view other than its own, so no handler can act on a page other than the one that sent it. The new tab is now registered with the extension manager and gets the ad block rules too. Found while fixing QA finding F-412. 2026-10-24: Let a session name from Edit Session out-rank an AI profile's fallback With a profile whose Title is AI-generated name and no AI title, the tab title falls back to the program's OSC 1 icon name before the session name. Editing the session name in Session > Edit Session left the title components alone for an AI profile, so after a program had set an icon name the tab kept it while the window showed the new name. Editing the name now turns on TemporarySessionName for an AI profile, as a manual rename or Set Title trigger does, so the deliberate name wins over both the icon name and the AI title. QA finding F-492. 2026-10-24: Fit the installer's buttons to their translated titles The shell integration installer's buttons had fixed widths sized for English. In German the second page showed Installationsprogramm herunt and the Internet-Free page showed berspring; Cancel, Continue and Skip were also clipped in de, ja, pt-BR or ru. On the second page, widen both choice buttons to the widest title, move the descriptions beside them over by the same amount and widen the page to make room (the window is fitted to the page when shown). Right-aligned buttons such as Cancel grow leftward to fit. On the Internet-Free page, widen any of Cancel, Skip and Continue that needs it and lay the row out again from the right edge with the nib's spacing. QA finding F-406. Fit the installer's Preview Command button to its translated title On the shell integration installer's Internet-Free page, the small Preview Command button beside the step list had a fixed width sized for English, so in Russian it read . 02ccd9b9f refit the other installer buttons but not this one, whose title is set in code (Preview Command, or Send Again while a command runs). When the page loads, widen the preview buttons to the widest title they can show, keeping their right edge, and narrow the step list beside them if they would otherwise overlap it. QA finding F-411. 2026-10-24: Let a browser tab's page follow appearance changes iTermBrowserWebView overrode viewDidChangeEffectiveAppearance without calling super. WKWebView's implementation is what tells the page that the appearance changed, so a page that supports both color schemes kept the scheme it had when the tab was created. Changing the theme from Minimal to Dark left open tabs light, and a tab opened under Dark stayed dark in Minimal, where its dark background kept the tab's chrome dark too. Now the override calls super. QA finding F-410. 2026-10-24: Show the page source in the browser's View Source View Source always showed No source available. showSourcePage stored the generated page in a per-context iTermBrowserSourceHandler and then loaded iterm2-about:source, but decidePolicyFor drops every local page context before the scheme handler runs, so a fresh handler with no pending source served the fallback. The local page manager now owns a single source handler for its lifetime and reuses it for the iterm2-about:source context, so the pending source survives the context reset. resetAllHandlerState also resets it since it may not be in the active contexts. QA finding F-387. Keep View Source on the main thread and drop superseded source When the network refetch of a page's source failed, viewPageSource fell back to reading the DOM by evaluating JavaScript from the URLSession completion queue. The completion now hops to the main queue first. Since the source handler now lives as long as the local page manager, source stored for a View Source that never got served could be shown by a later visit to iterm2-about:source. decidePolicyFor now clears it for main-frame navigations that stay in the tab and aren't to the source page. QA finding F-387. Keep View Source links with ampersands intact View Source linkified URLs by inserting into the code element's HTML, and the syntax highlighter then re-read that HTML and wrapped the & inside the real href attribute in an entity span, whose quote ended the attribute. Every link containing an ampersand was garbled and pointed at a bogus URL. Links are now swapped for placeholders while the highlighter runs and restored afterward. Link targets are decoded properly (the serialized text, then the source's own references in attribute context), resolved against the page URL, and HTML-escaped. Only http and https URLs (and file URLs when the page itself is a file) become links, so JavaScript: URLs in the source are no longer clickable. QA finding F-409. 2026-10-24: Fit the installer's utilities checkbox and description to their text On the first page of the shell integration installer, the Also install iTerm2 Utilities checkbox and its one-line, clipping description had fixed frames sized for English. In German the checkbox read iTerm2-Dienstprogramm and the description stopped after its first line; ja, pt-BR and ru were also too long. After the page loads, widen the checkbox to its title, up to the page's right margin, and let the description wrap. The description grows upward the same way the Internet-Free step list does: controls above it move up and the page grows, so the buttons at the bottom stay put. The window is fitted to the page when it is shown. QA finding F-406. 2026-10-24: Localize the units in Advanced Paste and Paste History sizes Advanced Paste built its chunk size and delay labels by hand (%@ kB, ms, sec, min), and NSString it_formatBytes, used by Paste History, the paste-rate toast and the Python runtime download prompt, hard-coded bytes, kB and so on. In a non-English UI these English units sat next to translated text and tick labels. Add LocalizedUnitFormatter. For an English UI it keeps the existing strings exactly. For any other UI language, byte counts go through MeasurementFormatter with UnitInformationStorage in the UI language, and the Advanced Paste delay units come from new catalog strings with de, ja, pt-BR, ru and zh-Hans translations (MeasurementFormatter left duration units untranslated for ru inside the app). The language is the UI localization, as with the relative time strings. QA finding F-405. 2026-10-24: Show the placeholder in the Toggle Setting and menu item pickers SearchableComboView only sets its default title when a selection fails, so a freshly created combo view shows a blank title. SettingPopupView and MenuItemPopupView recreate the combo view on every reloadData, which happens whenever the action changes in the key binding editor, so the Toggle Setting picker (and the Select Menu Item picker) stayed blank until a setting was chosen and the localized Select Setting title never appeared. Set the default title right after creating the combo view. A later selection replaces it. QA finding F-407. 2026-10-24: Use ps ucomm to find the shell for offline installer discovery tcsh overwrites the first character of its argv[0] with -, so the comm that discovery read from ps showed -csh for a tcsh started as `tcsh` and -sh for one started as `csh`. The latter still fell back to $SHELL, so a csh started from a zsh login shell got zsh-only syntax and the Internet-Free install hung. Discovery now asks ps for ucomm (the executable name, which tcsh can't overwrite) followed by comm, falling back to comm alone where ps lacks ucomm. It tries ucomm first, then comm and comm's first word, because xonsh runs under python and names itself, with its arguments, only in its process title. csh still maps to tcsh. Otherwise it falls back to $SHELL as before. QA finding F-451. 2026-10-24: Fix crash when AI Chats search finds a match The search results table measured each row by configuring a fresh ChatSearchResultCell and reading its fittingSize. Since the chat cells moved from auto layout to manual frame layout, the cell has no constraints, so fittingSize is zero. NSTableView raises when a delegate returns a zero row height, so inserting the first matching result crashed with an uncaught exception. Compute the row height with RegularMessageCellView.cellHeight(for: maxBubbleWidth:), which mirrors layout(), using the same bubble width the cell is configured with. If the result is ever non-finite or not positive, fall back to the height of a single-line bubble. QA finding F-389. 2026-10-24: Keep the AI Usage report alert a manageable size When a usage command's output passed the 1 MiB cap, the item showed Unreadable usage data without mentioning the cap, and the report alert put the whole diagnostic (about 1 MiB) into its text. The sheet was taller than the screen, its buttons were off-screen, and the terminal window was left pushed down after Escape. Show at most 2000 characters of the report in the alert (the clipboard and bug report still get it all), and prefix the diagnostic with a note when the output was cut off at the cap. Decode the diagnostic lossily so output cut mid-character is still shown. QA finding F-375. Cap the AI Usage report alert by rows as well as characters The report alert showed at most 2000 characters of the report, but a usage command like yes prints two-character lines, so that was still about 1000 lines: the sheet ran off the bottom of the screen with its buttons out of reach, and Escape left the terminal window pushed down. Also stop after 30 rows, counting a long line as the rows it wraps to (assuming 80 characters per row). The clipboard and bug report still get the whole report. QA finding F-388. 2026-10-24: Retire the global AI custom headers once models have their own The one-time migration that copies the former global AICustomHeaders list into each manual model treats a model with an empty header list as unmigrated (the editor stamps empty lists), and it never cleared the global list. On another Mac sharing the prefs, where the NoSync done flag is unset, a header the user had removed from a model was copied back in. Once configured models exist, turn off AICustomHeadersEnabled after the migration (also on launches where an earlier build already set the done flag) and whenever the models panel saves configurations. The legacy single-model path, which still inherits the global list, is left alone. The list itself is kept for older builds. QA finding F-091. Keep a model's emptied custom header list empty during migration The one-time copy of the former global AI custom headers into each manual model treated a model with customHeaders = [] as unmigrated and gave it the global list. An empty list is what the model editor saves after the user removes every header, so on a Mac whose NoSync done flag is unset, with shared prefs last written by a build that left the global headers enabled, a removed header came back. Only a model with no header list at all now gets the global headers. The empty-list rule existed because the editor could stamp an empty list before the lazily run migration; the models panel now runs the migration before it reads the configurations, so that can't happen. QA finding F-386. Carry the global AI custom headers into a converted legacy model A user with a single legacy manual AI model (no model configurations) sends the former global custom headers with its requests. Opening the models panel turns that model into a configuration, but the conversion left its headers out, so once the panel saved, the model sent none, and the global list was then retired. The conversion now copies the global headers when they are turned on, using the per-model migration's sanitizing, which it now shares. The sanitizing keeps well-formed entries one by one, so a header list with a malformed entry, which the legacy path dropped entirely, now carries its valid headers. The one-time alert shown when the panel opens no longer says that editing models loses the headers; it now says only new models start without them. Found while fixing QA finding F-386. 2026-10-24: Keep the Minimal theme's page tint from reaching the web view In the Minimal theme a browser session's view is light or dark to match the page's background color. The web view inherited that appearance, so once a dark page tinted the session dark, a page that supports both color schemes rendered its dark variant, whose dark background kept the session dark through reloads and later pages. In the Minimal theme, give the web view the app's own appearance so pages follow the system setting while the surrounding chrome still follows the page. QA finding F-424. 2026-10-24: Hide the AI-generated name title option for browser profiles With the aiGeneratedTabTitles advanced setting on, a browser profile's Session Title menu (in Settings and Edit Session) offered AI-generated name, but browser sessions are never AI-titled, so choosing it did nothing. Treat the AI component like the other terminal-only components: hidden for a browser profile unless it is already selected, so a stale selection can still be turned off. QA finding F-404. 2026-10-24: Give the AI Chats window's chat pane a minimum width At the AI Chats window's 400pt minimum width with the chat list shown, the chat pane was about 200pt wide, so even with the find bar's mode popup and counter hidden its search field was only about 65pt and showed a single character of the query. Give the chat pane's split view item a minimum thickness wide enough for the find bar to keep its minimum search field width beside the chevrons and close button. Shrinking the window now collapses the sidebar (or stops at the wider size where it can't) instead of squeezing the chat. QA finding F-385. 2026-10-24: Name settings.json, not its temp file, when a hook install write fails Installing the Claude Code hook over a dangling settings.json symlink writes a hidden .settings.json. file beside the link and renames it into place. When the directory isn't writable, the Foundation error for that temp file was passed through, so the alert named a UUID file the user never created. Report the failure against settings.json, keeping the error's code so the message reads as it does for the regular atomic-write path. QA finding F-454. 2026-10-24: Stamp a new chat's model vendor when the chat is created ChatBroker.create pins a new chat to the default model but stored no vendor for the pin; the manual-model stamp from the F-382 fix was only written by setModel, the launch backfill, or the refresh before a turn. So an empty chat pinned to a manual model on a local server, whose model was then renamed or removed in Settings, showed OpenAI in the toolbar and sent its first message (and any attached terminal context) to api.openai.com instead of failing. New chats are now built by ChatBroker.makeChat, which stamps the pin's vendor the same way setModel does. ChatBroker.create is the only place a chat is constructed. QA finding F-384. 2026-10-24: Add a regression test for Session Status rows following a job exit A Session Status row's name label kept showing the job that was running when the row was last configured (for example sleep) after the job exited, while the tab title went back to -zsh. Rows are configured only when the session's status changes, so between configurations the label has to follow the session's name variable on its own, and the label's evaluator stopped observing that variable once its asynchronous evaluation finished. This was fixed by 5b2327dd0. These tests drive ToolStatusCellView directly: they fail with that commit's iTermGenericEvaluator.m change reverted and pass with it. QA finding F-065. 2026-10-24: Substitute browser template placeholders in a single pass iTermBrowserTemplateLoader replaced each {{KEY}} in turn, in dictionary order, so a substituted value that contained {{OTHER_KEY}} could be expanded by a later pass. That let untrusted text (a failed URL, an error description, a page's source) pull other values into unintended contexts. Placeholders are now replaced in one regex pass over the template, so values are inserted verbatim; placeholders without a substitution are left as-is, and COMMON_CSS and INCLUDE are still handled first. With that, the error page no longer needs to escape braces itself and goes back to plain HTML escaping. The view-source page put the HTML-escaped page URL inside a single-quoted JavaScript string, where entities are not decoded, so relative links were resolved against a corrupted base URL. The URL now goes in an escaped data-url attribute and the script reads it through dataset. The no-source fallback fills URL with an empty string. Found while fixing QA finding F-402. 2026-10-24: Focus the pane under the pointer when a sheet closes With Focus Follows Mouse on, moving the pointer into another split pane while a sheet (such as Edit Tab Title) was open on the window could not move focus, because the window can't become key then. Focus follows mouse still counted the pointer as having entered that pane, so once the sheet closed and focus returned to the original pane, moving around inside the pane under the pointer did nothing until the pointer left it and came back. When focus does not follow the pointer into a pane that focus could otherwise move to, that pane is now remembered. When a sheet on that pane's window ends, the pane gets focus if the pointer is still in it and it isn't already focused. QA finding F-403. 2026-10-24: Count each bad Dynamic Profiles file once in the error alert After a file's profiles failed to load, the Ignoring dynamic profiles in because of an error. line was reported as an error of its own. That counted toward the alert's N additional errors may be seen in the log, so N bad files reported 2N-1 additional errors instead of N-1. The follow-up line is now only written to the log. Loading a file and logging that it was ignored is factored into -loadDynamicProfilesFromFileLoggingFailure:intoArray:guids: so a test can drive it. QA finding F-301. Don't double count Dynamic Profiles errors when the folder reloads twice When two bad Dynamic Profiles files appear at once while iTerm2 runs, the folder watcher can reload twice within a few milliseconds, before the error alert is shown. Each reload reported every file's error again and added it to the pending count, so two bad files reported 3 additional errors may be seen in the log. Each reload examines every file, so its errors now replace the pending errors from the previous reload instead of adding to them. Whether an alert is already queued is tracked separately from the count, and if a later reload finds nothing wrong the queued alert is not shown. The reload bracketing is factored into -performReloadPass: so a test can drive it. The existing release note for F-301 already describes this fix. QA finding F-421. 2026-10-24: Let Show menu bar icon change when Dock exclusion needs hotkey windows With Exclude from Dock and -Tab Application Switcher on, Settings showed Show menu bar icon checked and disabled, since the icon is then the only way back to Settings. That also happened with but only if all windows are hotkey windows on, where iTerm2 is an ordinary Dock app whenever a normal window is open and the icon follows the saved setting. The box claimed the icon was on while it was missing, and could not be changed. The checkbox is now forced only when exclusion does not depend on hotkey windows, and it refreshes when that setting changes. QA finding F-250. Force Show menu bar icon on while only hotkey windows keep iTerm2 out of the Dock With Exclude from Dock and -Tab Application Switcher and but only if all windows are hotkey windows on, the fix for F-250 stopped forcing the Show menu bar icon checkbox at all. When no ordinary window is open iTerm2 is out of the Dock and the menu bar icon is shown regardless, as the only way back to Settings, but the checkbox showed unchecked and enabled, and unchecking it did nothing. That is the state a user is in when they open Settings from the icon. The checkbox is now forced in that configuration while the app is actually a UI element, matching how the menu bar icon decides whether to show. iTermApplication posts a notification when the UI-element state changes so Settings refreshes the checkbox as ordinary windows open and close. QA finding F-423. 2026-10-24: Keep the find field's caret after changing the window style Changing a window's style between one with a title bar and one without replaces the NSWindow. Since the fix for F-139, focus returns to the view that had it, such as the find bar's search field, but making a text field first responder selects all of its text. The next keystroke then replaced the query instead of continuing it. The field editor's selected ranges are now remembered before the window is replaced and put back once the field has focus again in the new window. QA finding F-422. 2026-10-24: Escape untrusted text on the browser error page The internal error page (iterm2-about:error) inserted the failed URL, the error title and the error description into its HTML without escaping, and pasted the failed URL raw into a single-quoted JavaScript string for the Try Again button. A quote survives in a URL's absoluteString, so a link to a failing URL such as http://host.invalid/');alert(1);// could run script in the internal page, and markup in an error description (which can include a user-supplied file path) was rendered as HTML. All text substitutions are now HTML-escaped, with braces escaped too so a value containing a {{PLACEHOLDER}} can't be expanded by the template loader's later passes. The failed URL is put in an escaped data-original-url attribute on the button and read by the script through dataset, so it never appears in JavaScript source. The already-escaped TLS details are left alone, and the certificate chain label is now escaped for consistency. Privileges: the error page registers no message handler, and the local page manager only accepts a message whose name matches the current top-level iterm2-about: URL, so injected script could not drive the settings, history or other privileged handlers. It could run script in a trusted-looking internal page (spoofing) and navigate to other iterm2-about: pages, which navigation policy always allows. Found while fixing QA finding F-402. 2026-10-24: Translate the browser error pages button and footer The browsers page-load error page had a translated heading and message, but its Try Again button and its footer were English literals in error-page.html. The page shown for an ssh: URL whose host has no ssh integration connection was entirely English. Both templates now take those strings as placeholders filled with localized strings (with de, ja, pt-BR, ru and zh-Hans translations), HTML-escaped, including the host name on the ssh page. QA finding F-402. 2026-10-24: Shorten the German and Russian menu bar icon placeholders The German and Russian translations of the placeholder in Settings > Appearance > Generals menu bar icon text field were wider than the field and got clipped. Use shorter wordings ((global) and () for the scope note) that fit the field. QA finding F-401. 2026-10-24: Size the Set Profile Setting On/Off popup to fit its items The On/Off popup of the Set Profile Setting trigger action was a fixed 70pt wide, so the pt-BR Inativo was clipped to Inati. The popup is now at least 70pt and as wide as its longest item, but never more than half the editors width. QA finding F-400. 2026-10-24: Label the Profiles windows New Floating Pane button The New Floating Pane button in the Profiles window is icon-only and had a tooltip but no accessibility label, so VoiceOver read the SF Symbols name (screen sharing) instead of what the button does. Use its localized xib tooltip as its accessibility label, as the split buttons next to it already do. QA finding F-399 (remaining path of F-227). 2026-10-24: Treat a csh parent as tcsh in offline installer discovery On macOS, /bin/csh is tcsh, and ps reports a tcsh started from a zsh login shell as -csh. Shell discovery stripped the dash but did not recognize csh, so it fell back to $SHELL (zsh), typed zsh-only syntax into tcsh, and the Internet-Free install hung. Discovery now treats a parent named csh as tcsh. QA finding F-451 (follow-up to F-260). 2026-10-24: Keep a chat on a removed manual model from going to OpenAI A chat pinned to a manual model on a local OpenAI-compatible server (for example LM Studio or llama.cpp at http://127.0.0.1:8080) was sent to api.openai.com with its history once that manual model was removed or renamed in Settings. The F-380 fix stored each chat's pinned vendor but deliberately left manual models on non-Ollama APIs unstamped, since their vendor is guessed from the host and an unrecognized host counts as OpenAI. An unstamped pin that no longer resolved then fell back to OpenAI's recommended model in the chat UI and to the global default in routing. Such pins are now stamped with LLMMetadata.manualModelPinVendor, a value outside iTermAIVendor's range that means "a manual model". A pin with it resolves only among the configured manual models; when its model is gone the turn fails with an error that points to Settings, even if a built-in model has the same name. A manual model at its vendor's own public API host (api.openai.com, api.anthropic.com, and so on, but not Azure or a gateway) is stamped with that vendor instead, so it keeps falling back within that vendor as before. A Llama-named manual model on a non-Ollama API is a manual pin, not an Ollama one. Chats pinned before this change are stamped at launch while their manual model is configured, and the stamp is never changed by the backfill afterwards. Before each turn, a chat stamped with a cloud vendor or none moves to the manual stamp if its model is now a local manual model (for example, its URL was moved off a vendor's host), so a later removal fails instead of sending turns that went to the local server to that vendor. The chat UI keeps showing the missing manual model so a send fails clearly, and the provider binding rejects turns that name another provider's model. QA finding F-382. 2026-10-24: Keep the AI Chats list selection on the chat that moves to the top Sending a message moves its chat to the top of the AI Chats list. The list reloads on the model's change notification and re-selects the previously selected chats by ID, but it read those IDs by mapping the table's selected row indexes through the already reordered model, so it re-selected whichever chat now sat at the old row. The highlight stayed on that row while the transcript showed the moved chat. The list now records the selected chats' IDs whenever the selection changes, while rows still match the model, and re-selects those after the reload. QA finding F-383. 2026-10-24: Resize AI chat @-mention chips when the input's font changes An @-mention in the AI chat input is a rasterized image baked for one font. Changing the font of the input's text with the Font menu or Font panel enlarged the typed text but left the chips at their old size; they were re-rendered only on an appearance change, and always in the text view's default font. ChatInputView is now the input's text storage delegate. When an edit changes attributes, it re-renders each mention chip in the edited range whose character font differs from the font its image was rendered for. The appearance-change re-render also uses each chip's own font. QA finding F-371. 2026-10-24: Keep a re-bound tmux watch's names while the reattached pane is unnamed When a relaunch reattaches a tmux -CC pane, a held watch is re-bound to the pane's new session as soon as its window opens and takes its workgroup and role names from that session. At that moment the pane has no name yet (its session name is a blank " "), so list_watches, the status_update, and the chat notice showed a blank workgroup and role. The rebind still takes the new workgroup and role IDs, but keeps the watch's previous names when the new ones are blank. QA finding F-332. 2026-10-24: Keep an AI chat's pending timer armed when orchestration is enabled Enabling orchestration tore down the chat's dispatcher, which owns the task that fires a pending register_timer timer, but kept the timer saved. Nothing re-armed it, so it missed its deadline and fired only when the next orchestration tool call recreated the dispatcher, or at relaunch. OrchestratorClient.enableOrchestration now drops the dispatcher, writes the flag, and, if the chat still has pending timers or watches, builds a fresh dispatcher at once so it reconciles and re-arms them. ChatViewController's Enable Orchestration uses it. QA finding F-278. 2026-10-24: Resume AI chat watches at launch and let Stop cancel them without a dispatcher At launch only chats with a persisted timer got an orchestration dispatcher. A chat whose only watcher was a session watch kept the watch saved but nothing ran it: no screen polls, no tab-status checks, until something touched the chat. Because Stop is handled by the dispatcher, pressing Stop in such a chat neither cancelled the watch nor showed the cancellation notice, and the watch later fired and started the turn the user had tried to stop. Once launch-time session restoration is done, a chat whose watchers are all watches now gets a dispatcher too, if every one of its watches can still run (its session was restored, or it is an orchestration chat's tmux pane watch waiting for reattach). Any other such chat is still left until the user opens it, so dropping its watches doesn't start unattended agent turns at launch. OrchestratorClient also handles Stop itself: with a live dispatcher it calls the (idempotent) userDidStop, and with none it clears the chat's persisted watchers and publishes the same cancellation notice, which also covers chats left dormant under the administrator lockout. QA findings F-133 and F-279. 2026-10-24: Recognize it2ssh's EndSSH mid-frame in a restored session When an SSH Integration connection dies abruptly, the conductor hook treats the local it2ssh's OSC 1337;EndSSH as an implicit unhook. Inside a %output frame it only does so when the EndSSH carries this connection's it2ssh unique ID, so a nested it2ssh's EndSSH relayed in the output is not mistaken for it. A parser hooked in recovery mode, after a session was restored by relaunch or by framer recovery, never saw the SSH_INIT line and so did not know the ID. Killing the remote sshd while output streamed then wedged the tab: the local shell was in the foreground, but nothing was drawn. The restored conductor knows the ID, so pass the outermost conductor's it2ssh unique ID to the parser when entering recovery mode. Nested parsers still get nil, as their IDs are not saved. QA finding F-331. 2026-10-24: Draw only the cropped part of a Kitty placeholder image A Kitty Unicode-placeholder placement with a source crop drew the wrong pixels, two ways. Converting a cell's source rect to the bottom-up coordinates of the crop subtracted the crop's origin instead of adding it, so a crop of the top-left quadrant sampled below the image and drew nothing. And a cell in the letterbox padding around the image sampled outside the crop, so a strip of the neighboring part of the image appeared along the crop's edge. Add the crop origin, clip each cell's source rect to the crop and shrink its dest rect in proportion, and skip a cell that falls entirely in the padding. The Metal renderer now places a dest rect whose origin is not at the bottom of the cell correctly; before, every dest rect filled the whole cell so its origin was always zero. QA finding F-119. 2026-10-24: Decode JSON values in the remaining it2 commands it2 app get-focus, app theme, app version, profile list, profile show, profile get, the monitor commands and the tmux pane lookup all took JSON-encoded profile properties, variable values and function results and only trimmed the double quotes, so escapes iTerm2 emitted leaked into the output: a profile named Q/P "x" printed as Q\/P \"x\ and a session name with a quote or backslash kept its escapes. A JSON null also came back as the string null. Replace trimJSONQuotes with jsonStringValue, which decodes a JSON string, maps null to the empty string and passes other values through, and use it at every call site that trimmed quotes by hand. Rebuilt the checked-in it2 binary with make paranoid-it2cli. QA finding F-450. 2026-10-24: Keep Arabic joining at a selection edge in the legacy renderer With the GPU renderer off, the text of each background run was built and shaped separately, and runs were split wherever the selection or a find match started or ended. A selection edge inside an Arabic or Persian word therefore drew the letters on each side in isolated or initial forms instead of joined. On lines with right-to-left text, the foreground pass now combines runs across selection and find-match edges, so the word is shaped once. The attributed string builder takes the line's selected columns and picks the selected-text or match color per cell, and it no longer reuses the previous cell's attributes when the highlight changes between them. The legacy drawer already fills each color span of a multi-color string. Lines whose text color depends on the background (minimum contrast, dimming, thin strokes on dark backgrounds) and faint text still split. QA finding F-080. 2026-10-24: Keep each pane's size when the API changes a tab's layout A real Tab.async_update_layout() change lost a column or row and shrank the window a little each time. Two problems in setSizesFromSplitTreeNode: - A local split view variable shadowed the method's parameter and was nil for a session pane, so the panes were laid out with no room for the dividers. When the root then got its computed size, the point came out of the last pane, which lost a cell. - When only the split moved and the root kept its size, nothing fit the sessions to their new frames, so they kept their old grids and the window was sized from those. Rename the local and fit the sessions to their views before fitting the window to the tab. QA finding F-343. 2026-10-24: Put the cursor right after the session restored banner Appending the Session Contents Restored banner already moves the preferred cursor position onto the banner, but appendRestoredBanner then added the cursor's whole movement on top of that. When the cursor had been mid-line, or the banner wrapped, the rows were counted twice and a blank row was left between the banner and the shell's first output. The preferred cursor position is now simply where the cursor ends up after the banner: the start of the row below it. QA finding F-324. 2026-10-24: Don't treat a refused proxy icon drag as a click Dragging the Current Directory status bar component's folder icon when there was no directory to vend (deleted, nonexistent, or remote) made mouseDragged return before setting didDrag, so mouseUp saw a click and opened the component's menu. Once the mouse moves past the drag threshold, the gesture now counts as a drag whether or not a drag session can start. QA finding F-398. 2026-10-24: Punctuate the Cockpit's skipped-target status and drop gone chips When a Cockpit send skipped a closed target, the status appended Cockpit.SkippedGoneTargets to Cockpit.SentToSessions with a space, so every language got two sentences run together with no period. The skipped name was the chip label, which for a window or tab picked from the @ picker included the picker's keyboard shortcut (for example 2), stale once the window closed. The gone chip also stayed in the composer, so every later send skipped it again. The status is now one localized string with a plural substitution (de, ja, pt-BR, ru and zh-Hans translations), replacing the separate skipped-targets key. Picker rows keep the shortcut separate from the name, and a chip is labeled with the name only. After a send, chips for targets that no longer exist are removed from the composer. QA finding F-396. 2026-10-24: Don't archive a tmux integration pane on closure -hardStop skips archiving a tmux client, but -terminate sets the session's tmux mode to none before it calls -hardStop (a tmux client's termination is never undoable, so -hardStop runs at once), so the check never saw a tmux client. With Archive On Closure on, closing a tmux pane wrote an archive that Undo Close could not restore, and such archives just accumulated. -terminate now records that the session was a tmux client, and -hardStop checks that too. QA findings F-104 and F-335. 2026-10-24: Stop double-escaping the composer's first file suggestion With AI command suggestions on, the composer's early result (the longest common prefix of the file completions, built from already shell-escaped names, or a history entry) is put at the top of the completion list that comes back from the AI request. didFindCompletions then escaped every non-AI item again, so the row for "my file.txt" read "my\\\ file.txt" and a second, correctly escaped copy of the row appeared below it. CompletionItem now has a valueIsFinal flag. The early result is marked final and the shared escaping helper leaves final items alone. QA finding F-277. 2026-10-24: Withdraw the it2 client record on Shell > tmux > Detach A tmux integration client advertises itself to it2 with an @it2_client_ server option. -[PTYSession tmuxDetach] (Esc in the gateway) withdraws it before detaching, but Shell > tmux > Detach and the other callers of -[TmuxController requestDetach] sent only the detach, so the option lingered on the server. it2 ignores records of clients that aren't attached, so the only effect was a stale option. -requestDetach now withdraws the record before sending the detach. No release note: there was no visible effect. QA finding F-068. 2026-10-24: Answer iTerm2-only reports in tmux integration panes tmux answers most reports itself, so a tmux integration pane doesn't send them. OSC 1337 ReportCellSize, ReportWindowFrame and ReportScreenFrames are unknown to tmux and pass terminalShouldSendReport:YES, but they were sent with terminalSendReport:, which drops every report for a tmux client, so they got no reply at all. Add terminalSendReportAllowedInTmux:, which skips that check, and use it for these three. Other reports keep the tmux check. QA finding F-058. 2026-10-24: Don't let a dragged workgroup member become a float Swap With Session refuses a swap that would make a workgroup member float, but dragging the member onto a float in another tab went through -[MovePaneController dropFloatingPaneInTab:atWindowPoint:], which had no such check, so the member was installed as a float in that tab. The drop is now refused for a workgroup member, and -dropPlacesFloatingPaneOverFloat: says no for one, so a float doesn't offer itself as a drop target during the drag. QA finding F-257. 2026-10-24: Anchor tmux floats to the right pane while a filter is open -tmuxFloatAnchorsForParseTree: found the tiled session for each layout leaf by comparing candidate.tmuxPane. With Find > Filter open on a tmux pane, the tab shows a synthetic stand-in whose own pane id reads as 0, so when it came before the real %0 in the tab it was taken for %0, and a float lying over %0 was drawn over the filtered pane instead. d1a49cbff switched the other lookups to +tmuxLayoutPaneForSession: but missed this one. Add +sessionShowingTmuxPane:amongSessions:, which matches by +tmuxLayoutPaneForSession:, and use it for the anchors. QA finding F-131. 2026-10-24: Don't count hidden floats toward the GPU renderer's pane limit -updateUseMetal turns the GPU renderer off when a tab has six or more panes, and counted self.sessions, which includes every float, hidden or not. Hidden floats already don't use Metal and don't take part in the other Metal checks, but hiding floats could not bring Metal back. The limit now counts the tab's sessions less its hidden floats. QA finding F-129. 2026-10-24: Give floats restored from an arrangement the arrangement's name -restoreFloatingPanesFromArrangement: passed named:nil to -_recursiveRestoreSessions:, so a float made from a saved arrangement did not know which arrangement it came from, while the tab's tiled panes did. Change Profile in Arrangement was disabled for such a float, and the nag offering to fix a broken profile in the arrangement could not name the arrangement. The arrangement name is now passed down to the floats as well. Floats restored from an archive still get no name, as before. QA finding F-053. 2026-10-24: Ask tmux to resize a tmux float instead of resizing it locally A session-initiated resize (View > Restore Text and Session Size, the Python API's grid size, and so on) of a float goes through -[PTYTab sessionResizeFloatingPane:columns:rows:]. It declined tmux tabs, so a tmux float fell through to the tiled path, which resized the float's session in place and told tmux nothing. The content was laid out at the new size inside the float's unchanged frame, tmux kept the old size, and the float could draw blank until a later relayout. For a tmux float it now sends tmux a resize-pane by the difference in cells, as dragging the float's edge does, and the layout change that comes back resizes the float. QA findings F-048 and F-130. 2026-10-24: Don't highlight a pane under a float as a swap target While picking a pane for Swap With Session, each pane's SplitSelectionView highlights itself on mouse moved events from its tracking area. Tracking areas are not occlusion-aware, so a tiled pane under a float was highlighted too while the pointer was over the float, and two panes said they would be swapped. The click itself already went to the float. The picker now ignores the pointer, and clears its highlight, where a click would go to a float other than the one the picker is in. Other views over the pane, such as its own subviews, don't hide it. QA finding F-051. 2026-10-24: Keep focus on a float docked by Control-drag Dropping a dragged float on a tiled pane with Control held docks it via -[MovePaneController reallyDropInSession:half:atPoint:]. Removing the active float from its tab activates the frontmost remaining float, and the split that follows only activates the moved session when its target is the active session. So with another float open, focus went to that float instead of the docked pane. With no other float, the tiled pane that took over was usually the target, which hid the problem. Docking a float in its own tab now activates the docked session, as Dock Floating Pane already does. QA finding F-050. 2026-10-24: Keep a maximized float's earlier frame on Undo Close Undo Close of a live float (within the undo timeout) restores it with -addRevivedFloatingSession:fromArrangement:, which passed NSZeroRect for the frame from before maximizing. The arrangement records that frame for a maximized float, but it was dropped, so the float came back as a plain float filling the tab and unmaximizing it could never return it to where the user had it. The archived path already passed it. Both paths now read the saved frame from the record the same way, so a revived maximized float comes back maximized and unmaximizes to its old frame. QA finding F-148. 2026-10-24: Grow the Internet-Free installer's step list to fit its text The send-shell-commands page shows its steps in one text field with the fixed height from the nib. The message added for a failed script write wraps to two lines (three in German), so the steps after it were cut off: the end of step 4 in English, and all of step 4 in German. After the steps are rendered, the field is measured with its cell at its fixed width, keeping the nib height as the minimum. The field grows upward by the difference, the controls beside it move up with it, the page grows by the same amount so the buttons at the bottom stay put, and the window controller refits the window to the page. QA finding F-381. Test that translated installer steps fit the step list The Internet-Free installer's step list had a fixed height sized for English, so in German step 4 for nushell was cut off after two lines. The step list now grows to fit its text (sizeStepListToFit). Add a regression test that loads the real nib, gives the step list the nushell steps in every shipped language, and checks that the text fits, the list's top stays put, and the buttons along the bottom neither move nor overlap the steps. QA finding F-364. 2026-10-24: Keep a running program's scroll region when restoring its session A restored grid read its scroll region (DECSTBM) from the saved state, but a restored session is created at the wrong size and then fit to its window, and every resize resets the scroll region. After a crash and relaunch, a program that was still running got full-screen scrolling instead of the region it had set. The grid now remembers the saved region and the size it was saved at. After the window fits its sessions, the region is put back if the grid is at the saved size again and nothing has set a region since. A restored session that is not reattached to its program discards it. QA finding F-225. 2026-10-24: Keep emoji ZWJ sequences and Base64 pastes intact when stripping With the advanced setting Strip zero-width bidi/format characters when pasting? on, the strip removed every ZWJ, so emoji ZWJ sequences such as and fell apart into separate emoji. It also ran before Base64 encoding, so an Advanced Paste with Base-64 encode no longer decoded to the copied bytes. Now a ZWJ between two pictographic characters (skipping a variation selector or skin tone modifier before it) is kept, approximating the UAX #29 emoji ZWJ rule. Other format characters, including a ZWJ between letters, are still removed. The strip is skipped entirely for Base64 pastes. QA findings F-075 and F-207. 2026-10-24: Draw the copy mode cursor where its character is drawn Copy mode keeps its cursor in logical coordinates, as it moves and selects in logical space, but both renderers drew the cursor at that coordinate as if it were visual. On a reordered right-to-left line the cursor appeared in the blank area on the far side of the line and walked across empty cells while the selection grew from the real text. Add visualCopyModeCursorCoord to the drawing helper, which maps the column through the line's bidi table the same way the terminal cursor is mapped, and use it in the legacy and Metal renderers. QA finding F-082. 2026-10-24: Keep pre-base vowel signs before their consonant in the legacy renderer The CoreGraphics renderer moves each CoreText glyph into its grid column, anchoring every column to its own first glyph. A pre-base vowel sign such as Devanagari in is stored after its consonant, so it belongs to a later column, but CoreText draws it to the left of the consonant. Anchoring it to its own column drew it after the conjunct, so read . Before per-column anchoring (3.7.4) the order was right. Now columns are gathered into groups first. A glyph that starts a new column but sits left of the previous group's baseline and belongs to a later character than any in that group joins the group, whose baseline becomes its x. The cluster keeps CoreText's internal layout and starts at its first cell. A reversed left-to-right island in a right-to-left line never matches, because its later columns hold earlier characters, so it is still placed column by column. QA finding F-081. 2026-10-24: Start legacy underlines at the leftmost drawn cell of an RTL run The CoreGraphics renderer drew an underline or strikethrough from the position of the first character in the attribute run. On a bidi line the positions are visual, so for a right-to-left run the first character is drawn rightmost and the line started about a word's width too far right: printf ' \e[4m' underlined the end of and the space instead of . Now the line, and the underline mask that cuts around descenders, start at the smallest position in the run, in both the attributed-string and fast paths. Left-to-right runs are unchanged because their first position is already the smallest. QA finding F-078. 2026-10-24: Anchor the Look Up highlight where an RTL word is drawn Look Up finds the word under the pointer in logical coordinates, then anchored its highlight and popover at pointForCoord of the range's logical start. On a reordered right-to-left line that column is far from where the word is drawn, so the yellow highlight landed over blank cells at the other side of the row. Now the anchor is the leftmost visual cell of the word range, via the bidi-aware extractor. Left-to-right lines are unchanged. QA finding F-320. 2026-10-24: Drop alternate-screen bidi tables when reordering is turned off Turning on the advanced setting Do not reorder right-to-left text in the alternate screen while a full-screen app showed RTL text left the alternate grid's existing bidi tables in place. shouldPopulateRTLState returned NO in that mode, so populateRTLState never ran the branch that clears them. The renderer kept drawing the rows reordered from the grid's tables while selection treated them as unreordered, so drags highlighted and copied the mirror-image cells. Now shouldPopulateRTLState returns YES once more when the alternate grid still has bidi tables, so they are dropped and the rows redraw in logical order. QA findings F-074 and F-313. 2026-10-24: Treat Azure's sovereign and serverless AI hosts as Azure AI services With the API key set to Automatic, only *.openai.azure.com, *.cognitiveservices.azure.com and *.services.ai.azure.com counted as OpenAI's own host. Azure OpenAI in Azure Government (*.openai.azure.us, *.cognitiveservices.azure.us) and Azure China (*.openai.azure.cn, *.cognitiveservices.azure.cn), and serverless model deployments on *.models.ai.azure.com, got no key, so users there had to pick it by hand. The sovereign hosts also failed the *.azure.com test that picks Azure's request format, so a key chosen explicitly went out as a Bearer token instead of in the api-key header. All of these hosts are run by Microsoft, so add them to hostIsAzureAIServiceAPI, and have hostIsAzureAIAPI accept every Azure AI service host. Look-alike hosts are still refused. QA finding F-373. 2026-10-24: Don't double the typed directory when expanding {{FILE}} Typing please delete logs/er and getting the suggestion `rm logs/{{FILE}}` produced rm logs/logs/error.log: {{FILE}} is expanded with the whole typed word plus the completion suffix, and the model had already written the directory. When the text right before {{FILE}} starts a shell word (optionally after ./) with the directory part of the typed word, substitute the name without that directory. The prompt also described the file completion suffixes (ror.log) as the longest common prefix of valid filename completions. Give it the full names (typed word plus suffix) instead. QA finding F-372. 2026-10-24: Stop logging every failed AI plugin validation c4802455a stopped Plugin.load() from re-logging the same load failure on every probe, but iTermAIClient.validate() still put the error in the retrospective log each time it failed. With the plugin missing, Settings > AI probes about every 10 seconds plus on every reload click or AI tab visit, so the ring filled with copies of the same line. load() already logs each distinct failure once, so validate() now only DLogs it. A test seam lets the test make the plugin look missing. QA finding F-379. 2026-10-24: Escape the session name in the orchestration grant notice When an @-mention granted a chat control of a session, the notice inserted the session's name into Markdown unescaped, so underscores were read as emphasis: my_dev_box (-zsh) was shown as mydevbox (-zsh). Escape the name with escapedForMarkdown before formatting the notice. QA finding F-113. 2026-10-24: Refresh the chat's permission buttons after an info menu change The permissions bubble's buttons show each category's state (View Contents: Ask). Only a click on one of those buttons reloaded the bubble, so changing a permission from the chat's info (i) menu left the buttons showing the old state until the chat was reopened. toggle(permissionCategory:) now reloads every permissions bubble in the transcript, so both paths refresh them. QA finding F-109. 2026-10-24: Escape control characters in claude_usage.sh's JSON json_escape only escaped backslash, quote, newline and tab, so claude output containing ESC or BS (for example colored stderr) produced invalid JSON. The AI Usage item then showed a generic Unreadable usage data instead of the script's message. The parser's jsonesc had the same gap for usage-line details. Escape every other C0 control character and DEL as \u00XX in both. QA finding F-245. 2026-10-24: Validate custom headers before an AI Test Connection Save refused a custom header with an invalid name (such as one with a space) or a value containing a newline, but Test Connection didn't check: the request builder silently dropped the header and the test reported Connection Succeeded, so the user couldn't tell the header would never be sent. Move the header check into AICustomHeaders.validationFailure(forHeaders:) (same strings and keys) and run it from both Save and Test Connection. QA finding F-248. 2026-10-24: Fix the wording of AI network errors A URLSession failure was reported as HTTP request failed with The request timed out., a broken sentence shown in Test Connection alerts and chat error bubbles. Use HTTP request failed: . Errors for a manual model on an unrecognized host also said Error from Unknown Platform. LLMProvider.displayName now falls back to the endpoint's host name before Unknown Platform. QA finding F-247. 2026-10-24: Let the user stop a running AI Test Connection Test Connection disabled its button for the whole test. Against an unreachable host the request only fails at the AI idle timeout (1200 seconds by default), so the editor sat on Testing for minutes with no way out short of cancelling the editor. AIConnectionTester.test now returns an AIConnectionTest that reports its outcome once and can be cancelled, which also cancels the plugin request. While a test runs the button reads Stop Test and stops it; cancelling the editor stops it too. The timeout itself is unchanged, since a slow local model can legitimately take that long. QA finding F-246. 2026-10-24: Return focus to the chat input when the chat find bar closes Pressing Escape in the AI chat find bar removed the bar while its search field was first responder, leaving the window with no first responder, so anything typed afterwards was silently dropped. When the bar had focus at close, make the chat input's text view first responder. QA finding F-094. 2026-10-24: Keep the AI chat find bar's search field usable when narrow The chat find bar laid out its mode popup (150pt), counter and chevrons at fixed widths and gave the search field whatever was left. In the 400pt inline chat that left about 20pt, and in a minimum-size chat window with the sidebar shown the field had zero width and the counter and chevrons were pushed off. Reserve a minimum width for the search field and drop the mode popup, then the counter, when they would not leave that much room. The popup decision assumes a typical counter width so it doesn't flicker as the counter text changes while typing. QA finding F-095. 2026-10-24: Send Fetch Models through the AI proxy Fetch Models (and the Ollama model cache's /api/tags and /api/show probes) used URLSession.shared, so they ignored the aiProxy advanced setting and failed behind a required proxy, while chat requests and Test Connection went through it. The error also blamed Ollama for any server. Factor the proxy dictionary out of PluginClient into AIProxySetting and give discovery requests a per-task session that uses it. Reword the unreachable-server error so it names the server rather than Ollama. QA finding F-093. 2026-10-24: Keep the Hotkey settings labels clear of their controls In Settings > Keys > Hotkey, the right-aligned Hotkey Window:, App Hotkey: and Open Quickly Hotkey: labels ended at x=261, but the localization relayout moved the controls beside them to x=255. The colon of App Hotkey: was drawn under its checkbox and Open Quickly Hotkey: touched its shortcut field. Shrink the labels to 251 points so they end before the controls with a small gap; every shipped translation still fits. QA finding F-234. 2026-10-24: Widen the AI model editor's API key popup to show its choice The API key popup in the Add/Edit Manual AI Model sheet was a fixed 210 points wide, so with a local endpoint its Automatic (none for a local endpoint) choice read Automatic (none for a local, and translations were cut off sooner. Whenever the choice or its title changes, the popup now widens to show the selected title, up to the end of its row, and the key field beside it follows. The key field is shown only for This models own key, which is short, so the popup is back to its usual width whenever the field is visible. QA finding F-338. 2026-10-24: Make room for translations in the manual AI model editor The Add/Edit Manual AI Model sheet lays out its form for English: a 150-point label column, checkboxes as wide as the right column, and a 150-point Test Connection button. Longer translations were clipped with no ellipsis, such as de Benutzerdefinierte Head, pt-BR Mximo de tokens de res, the ru Ollama checkbox and ru . After building the form, the editor measures its labels and checkboxes. The label column widens to fit the longest label and the right column to fit the longest checkbox; the window grows by the difference, the right column moves over, fields that reach the right margin widen, and the buttons pinned to the right edge move with it. The Test Connection button grows to fit its title and the spinner follows it. English is unchanged. QA finding F-276. 2026-10-24: Size the AI chat find bar's mode popup to fit its titles The mode popup in the AI chat find bar had a fixed width of 150 points, so in Portuguese Ignorar maisculas/minsculas was truncated to Ignorar maiscul. English Regular Expression needed a little more than 150 points too. The popup is now as wide as its longest title in the running language, and never narrower than before. QA finding F-111. 2026-10-24: Size the website permissions columns for their translations The website permissions panel (Settings > General > Magic > Manage Permissions) gave its Permission and Allowed columns fixed widths that suited English, so German showed JavaScript ausfhr and Russian Java and a header. Each column is now as wide as its header and the permission names need, and never narrower than before, so English is unchanged. The Website column takes what's left. QA finding F-361. 2026-10-24: Tell Badge and Icon apart in Russian Russian translated both Badge: and Icon: as :, so Settings > Profiles > General showed two rows with the same label. is the usual word for an icon (and is used for icons throughout the app), so the Badge labels are now :, matching the pane's search term . The badge field's placeholders and tooltip use the same word. QA finding F-394. 2026-10-24: Widen the Profiles window's New Tab button for its translation The New Tab button in the Profiles window had a fixed width that suited English, so the Russian title was truncated to . The New Tabs in New Window button was a little too narrow for its Russian title too. New Tab now grows to the left to match New Window's width, and New Tabs in New Window gets 10 more points, both staying clear of their neighbors. QA finding F-393. 2026-10-24: Widen popup date columns to fit translated relative dates The Command History, Recent Directories and Paste History popups show relative dates in a column whose width suited the English wording. In a non-English UI the dates come from NSRelativeDateTimeFormatter, which is longer in some languages, so German entries were clipped to Vor 12 Sekunde. After a popup reloads and sizes its table, it measures every row's date and widens the date column to fit the longest, taking the room from the value column so the table's width doesn't change. QA finding F-312. 2026-10-24: Fit the Smart Selection sheet's translated labels In German and Russian the Smart Selection rules sheet clipped its title, the No rule selected placeholder, the Load Defaults button, the debug logging checkbox, the Regular expression label, the Syntax Help link and the visualizer link. - The title spans the rules table, the placeholder spans the detail area (centered), and both links get wider frames. - The Load Defaults button grows to fit its title, and the checkbox after it moves over and grows to fit, stopping before the help button. - The Regular expression label wraps to a second line when it doesn't fit its column, moving the Syntax Help link down. - The Russian Syntax Help link, which can't fit the label column, is shortened to . English is laid out as before. QA findings F-305 and F-319. 2026-10-24: Fit Advanced Paste's translated labels In Russian, Advanced Paste clipped several labels: the Transforms header, the chunk size and delay titles (which ran into the values beside them), the Minimum Duration title, the regular expression placeholder and the substitution help text below it. German and Portuguese clipped some of the help text too. - The Transforms header and the help labels get wider frames. - The chunk size, delay and minimum duration values move right to follow a title that doesn't fit before them, so English is unchanged; the duration row is wider to hold the moved estimate. - Where the column can't hold a translation beside its value or in the room the field has, the translation is shorter: the de and ru delay titles, the ru placeholder, and the de and ru help text. QA finding F-302. 2026-10-24: Keep the donation link from truncating the window title The Settings and Edit Session windows show a randomly chosen donation call to action in the title bar. The long ones (Support the creator of iTerm2 Donate now! in English, and most of the Russian translations) left no room for the centered title, which was truncated to Edit Sessi or and could squeeze the toolbar search field down to an icon. The window now works out how wide the link can be while leaving its centered title intact, from its width and title when the link is added, and picks among the calls to action that fit. If none fits it shows the narrowest one. QA findings F-230 and F-300. 2026-10-24: Grow the crash reporter's explanation to fit its translation The explanation at the top of the crash reporter had a fixed frame sized for English, so the German text needed a sixth line and was cut off after von diesem Absturz zu erfahren und. The crash reporter now measures the explanation at the label's width once it has set the text, grows the label downward by the difference and shrinks the tab view below it by the same amount, so the window keeps its size. English is unchanged. QA finding F-228. 2026-10-24: Translate Show Toolbar and Bell sound, and match pt-BR profile tags The browser menu's Show Toolbar item and the Bell sound: label in Settings had no entries in their XIB catalogs (command-line builds don't add new XIB strings), so both were English in every language. Add the entries at Xcode's sort position with de, ja, pt-BR, ru and zh-Hans translations. pt-BR labeled the profile tags field Etiquetas: right next to the Tags > button, and the profile list and tag column also said Etiquetas while the tooltip, tips and search used Tags. Use Tags for profile tags throughout. Found while fixing QA localization findings. 2026-10-24: Localize the AI tool-call bubbles and the Toggle Setting picker The bubbles that describe an orchestration tool call in AI chat (reading a screen, typing into a session, posting a clipping, setting up a watch, and so on) were English literals, as was the Select Setting placeholder of the key binding editors Toggle Setting picker. Each bubble is now a complete localized sentence, with separate strings for the scroll directions, the Code Review prompt kinds, an untitled clipping and a watch on the linked session instead of injecting English fragments. Session and workgroup mentions, quoted previews, commands and state names are still injected as self-contained values. Translated into de, ja, pt-BR, ru and zh-Hans. Found while fixing QA localization findings. 2026-10-24: Use the Internet-free installer when the website rejects nushell The online shell integration installer pipes the install script from iterm2.com into bash. The deployed script predates nushell support and says nu is unsupported, so choosing the default online option in a nu session failed even though the bundled (Internet-free) installer handles nu. The website script is deployed separately from the app. When the online script reports that nu is unsupported, switch to the Internet-free installer instead of showing the unsupported-shell error. Once the website serves an installer that supports nu this path is no longer reached. QA finding F-420. 2026-10-24: Restore the find field's caret when focus follows the mouse back With focus follows mouse moving focus within panes, returning the pointer to a pane whose find field had focus made the field first responder again, and NSTextField selects all of its text when it becomes first responder. The next key typed replaced the query. Before moving focus away, remember the selection of the text field being edited in the window, and restore it after making that field first responder again. If the field's editor is still the window's first responder (focus went to another window and back), leave it alone instead of making it first responder again. QA finding F-038. 2026-10-24: Keep a failed pairing's error when AI is toggled refreshGateState rebuilds the ready-to-pair state when AI availability changes so its AI advisory stays current, unless a pairing is in progress, which it detected with isListening. A failed pairing is no longer listening, so turning generative AI on while the window showed Pairing failed: wiped the error, its tooltip and the QR code and went back to the idle Show QR Code state. Track whether the top shows a pairing failure (cleared whenever the top changes state) and don't rebuild while it does. QA findings F-100 and F-336. 2026-10-24: Forget cached password manager account names when auth is revoked Fetching accounts saves their display names in a class-level cache so the Open Password Manager trigger's parameter popup can list them. The cache was never cleared, so after a screen lock revoked authentication the trigger editor still showed saved account and user names without re-authenticating. Observe the data source provider's revocation notification for each password manager class and clear its cache then. Clearing posts the accounts-loaded notification, so an open trigger editor reloads. QA finding F-323. 2026-10-24: Don't animate status bar sparklines in Low Power Mode In Low Power Mode the sparkline's scroll animation set preferredFrameRateRange to 1 fps, but that is only a hint: on an M1 in real Low Power Mode the graph kept moving smoothly between updates, so the energy saving didn't happen. In Low Power Mode, move the graph in one step per update without an animation, as the AnimateGraphStatusBarComponents=NO path already does. QA finding F-391. 2026-10-24: Tint the workgroup git arrows for the toolbar's appearance The workgroup toolbar's git item supplies labelColor as the tint for the ahead/behind arrow bitmaps, and the tinting resolves that dynamic color against the app's theme appearance. With the Minimal theme the toolbar takes its appearance from the terminal background, so a dark background in a light system gave light text but black arrows. Resolve labelColor against the toolbar view's effective appearance before handing it to the string maker. The item already rebuilds its string when that appearance changes. QA finding F-334. 2026-10-24: Stop swallowing keys while an announcement slides away A Press any key to dismiss announcement (such as the clear-scrollback one shown after `clear`) stays the session view's current announcement for the 0.25 second slide-out after the first key dismisses it. handleKeyDown: kept returning YES for dismissOnKeyDown announcements during that time, so every key typed then was eaten: typing `echo LOSTTEST4` right after `clear` ran `OSTTEST4`. Once the announcement is dismissing, return NO so the keys reach the session. Only the key that dismissed it is consumed. QA finding F-282. 2026-10-24: Count each arrangement session's command once in the trust gate A session started with a command is saved with both a Program of type Command and a profile whose Custom Command is Yes with the same command. RiskAnalyzer added the program count and the custom command count, so one command was reported as 2 custom commands that will run when the arrangement opens, for tiled panes and floating panes alike. Count a session as running a command once if either its Program or its profile names one. QA finding F-052. 2026-10-24: Drop the doubled period from the ssh sessions ended banner sshEndBannerTerminatingCount:newLocation: put a period in the preamble for more than one session and then appended another, so the banner read 2 ssh sessions ended.., or 2 ssh sessions ended. now at host. with a location. Leave the period off the preamble, as the one-session case does. Also mark the location parameter nullable, which it is. QA finding F-329. 2026-10-24: Keep -suite when LetsMove relaunches the app LetsMove's Relaunch() ran `/usr/bin/open ` with no arguments, both after moving the app to Applications and after the de-translocation relaunch. An instance started with -suite NAME came back on the default settings domain and opened that domain's restored windows. Forward a -suite argument from the current process to the relaunched one with `open --args -suite NAME`, shell-quoted. QA finding F-392. 2026-10-24: Fail -compare-rendering when another instance holds the lock -compare-rendering suppresses alerts by auto-answering them with the first button. When another instance held the suite's instance lock, that picked Quit on the iTerm2 Is Already Running warning, so the process exited with status 0, printed nothing and ran no comparison. A script would treat that as success. In compare-rendering mode, print a RESULT: ERROR line naming the other process to stderr and exit with status 1 instead of showing the warning. QA finding F-220. 2026-10-24: Leave a tab alone when the API sets an unchanged layout Tab.async_update_layout() with no changes sent back the tree it was given, and apiServerSetTabLayout:handler: applied it anyway. Turning each pane's grid size into a view size and fitting the sessions back to those views can lose a cell, so every no-op call shrank the panes and the window by a column (a point for a browser pane). When every session's requested grid size matches the current one, report success without touching the layout. A real change still goes through setSizesFromSplitTreeNode:, whose rounding is not addressed here. QA finding F-073. 2026-10-24: Return JSON null for built-in functions that return NSNull functionInvocationDidCompleteWithObject:error:completion: encoded the result with it_jsonStringForObject, which returns nil for a top-level NSNull. Functions that succeed with no value (set_floating_pane_frame, raise_floating_pane, dock_floating_pane, and move_session_to_new_tab or _window for tmux) return NSNull, so the response had an empty json_result and the Python client raised JSONDecodeError instead of returning None. Encode NSNull as "null" in a small class method used for the result. QA finding F-128. 2026-10-24: Keep Remember unticked when the basic-auth prompt returns After the user opened the password manager from the basic-auth prompt and backed out of the picker, the coordinator re-prompted with rememberByDefault set whenever Remember was offered, so the checkbox came back ticked although the user never ticked it, and OK then tried to save the credential. Carry the checkbox state with the open-password-manager outcome, store it on the challenge context, and restore it on the re-prompt. Without a recorded state, use the first prompt's rule (on only after a saved credential was rejected). QA finding F-033. 2026-10-24: Count Advanced Paste preview lines correctly The Advanced Paste stats label counted lines with -[NSString numberOfLines]. NSStringITerm defines that with lineRangeForRange:, but SmartSelectionController's NSString(iTermTextDataSource) category also defines numberOfLines as the number of LF-separated components, and that one won at runtime. So 1\n2\n3\n4\n5\n read as 6 lines, and the same text with Convert CRLF and LF to CR checked read as 1 line. Rename the NSStringITerm method to it_lineCount so it can't collide, and compute the label from the preview string through a new class method that uses it. The Russian translation also put the line count in the nominative ( 1 ); use the prepositional forms. QA finding F-303. 2026-10-24: Let Cmd-W close the Cockpit Shell > Close is wired to closeCurrentSession:, not performClose:. When the Cockpit panel was key nothing in its responder chain implemented it, and PseudoTerminal's validation enables the item only when the key window's controller responds to it, so the item was disabled and Cmd-W could not close the panel. Implement closeCurrentSession: on the Cockpit window controller to perform a close, as the Companion pairing window and other panels do. QA finding F-397 (found while fixing F-377). 2026-10-24: Focus the number-of-spaces field in the paste-with-tabs prompt The You're about to paste a string with tabs warning has an editable tab-size field in its accessory view but never set the warning's initialFirstResponder, so typing a number without clicking the field first did not reach it. Build the warning in a class method that also makes the field the initial first responder, and expose the accessory's text field. Found while checking QA finding F-018. 2026-10-24: Clamp SetWindowFrame to the title bar's minimum width The SetWindowFrame clamp used the largest tab minimum plus decorations and the window's minSize as the smallest allowed frame. For a titled window that is far narrower than a user can drag it: AppKit also keeps a titled window at least as wide as its title bar buttons need, so SetWindowFrame=0;0;0;0 produced a 24-point sliver with a 2x2 grid while drag-resizing stopped at about 87 points. Also enforce NSWindow's minimum frame width for the window's style mask when it is titled, the same limit live resize hits. QA finding F-395. 2026-10-24: Disable Prefer integrated GPU on a Mac without both kinds of GPU The Advanced GPU Settings sheet's view controller disabled Prefer integrated to discrete GPU unless Metal reported both a low-power and a discrete GPU, but on a single-GPU Mac the checkbox was still enabled and toggling it showed the restart warning and changed the setting. The checkbox's preference binding had no shouldBeEnabled, so the setting's enabled state was left to the view controller alone. Factor the check into +canPreferIntegratedGPU and give the binding a shouldBeEnabled that uses it, applied as soon as the control is defined. QA finding F-254. 2026-10-24: Remove two advanced settings that nothing reads Settings > Advanced listed Disable the window shadow on Mojave when the window has a transparent session and on High Sierra and earlier, but nothing read either key any more, so toggling them did nothing. Remove both settings and their catalog entries. A value a user stored stays in their defaults, unused, as it already was. QA finding F-222. 2026-10-24: Show the default in a string popup whose saved value isn't in its menu Settings selected a string popup's item with -indexOfItemWithRepresentedObject: and skipped the selection only when that returned NSNotFound. It returns -1 when nothing matches, so a value missing from the menu (for example a Dynamic Profile naming a progress bar color scheme that doesn't exist) went to -selectItemAtIndex:-1, which cleared the selection and left the popup blank. Add -it_selectItemWithRepresentedObject:fallback:, which falls back to the setting's default value, and use it for string popups. A nil or NSNull value still leaves the popup alone, and when neither value is in the menu it still shows no selection, so the keyboard locale popup stays blank when no locale is set instead of showing some other input source. QA finding F-265. 2026-10-24: Don't move a hotkey window to a tmux window's saved position With Settings > General > tmux set to open tmux windows as tabs in the attaching window, running tmux -CC attach in a hotkey window that held only the gateway tab applied the tmux window's saved origin to the hotkey window, leaving it mostly off screen until it was hidden and shown again. A hotkey window's position comes from its profile, so the saved origin is no longer applied to one. The decision is factored into +shouldApplySavedOriginUsingOriginalWindow:initialTabs:initial:isHotKeyWindow: so it can be tested without a tmux server. QA finding F-273. 2026-10-24: Update pane title bars for sessions exchanged by Swap With Session -swapSession:withSession: refreshed each tab's pane titles and refit its sessions before updating the tabs' view-to-session maps. -sessions finds sessions through that map, so each tab skipped the session it had just received. Swapping the lone pane of a one-pane tab into a split tab left it without a title bar beside neighbors that had one, gave the one-pane tab a title bar, and could change the window's height. The maps are now updated right after the sessions' delegates change, before anything asks either tab for its sessions. QA finding F-317. 2026-10-24: Don't copy Lock Location to a duplicated window Window > Duplicate Window opened the copy from the original's full arrangement, which includes its Lock Location state. The copy was locked to the original's frame, so it opened exactly on top of it and snapped back there when moved. The duplicate is now built with representsThisWindow:NO, the same way a tab group saved as an arrangement is, which leaves the lock out. QA finding F-037. 2026-10-24: Keep keyboard focus in a window after changing its style Switching between a window style with a title bar and one without (for example Window > Window Style > No Title Bar) replaces the NSWindow. The replacement was only ordered front, so after the old key window closed there was no key window and the new one's first responder was the window itself. Typing went nowhere, and menu items that act on the current window, including Window Style, were disabled until the user clicked the window. The replacement window now becomes key when the old one was. Its first responder is the view that had focus before, if that view is inside the content view that moves to the new window (such as the find bar), and otherwise the current session's view. QA findings F-139, F-154, and F-040. 2026-10-24: Keep Cmd-H, Cmd-Q, Cmd-W, and Cmd-M out of alerts' automatic shortcuts An alert's buttons get an automatic Cmd- shortcut from their labels, so in the Cant Find LastPass CLI alert Cmd-H pressed Help (opening a web page) instead of hiding iTerm2. Those four letters are the system-wide Hide, Quit, Close, and Minimize shortcuts, so automatic shortcuts now skip them. A shortcut the caller assigns is still kept. QA finding F-337. 2026-10-24: Keep Cmd on an alert's shortcuts when it is shown again makeAlert wrote the shortcuts it chose back into each action's keyEquivalent: the Return or Escape that NSAlert gave a button, and the automatic Cmd- taken from the label. When the same warning was built again, as Permanently Forget Saved Selection does to re-show it, those values looked like ones the caller had chosen and were copied to the buttons with no modifier mask, so a plain letter pressed a button (bare a opened Advanced Paste). The automatic shortcut now lives in a private automaticKeyEquivalent property that is recomputed each time, and the action's keyEquivalent is never changed, so every build of an alert gets the same shortcuts. QA findings F-028 and F-366. 2026-10-24: Keep a local-model chat on Ollama when its model disappears A chat created with Llama (Local) while Ollama was running is pinned to the discovered tag, e.g. qwen-fake:1b. If that tag later disappeared from Ollama's list, the pinned name no longer resolved. Routing only refused unresolved names containing "llama", so AIConversation fell back to the global default vendor and the chat UI mapped the name to OpenAI's recommended model: the chat and its history were sent to api.openai.com (or DeepSeek, for a deepseek-named tag). Each chat now stores the vendor of its model pin (Chat.modelVendor, a new migrated column), stamped whenever the pin changes. Existing chats are backfilled when the chat list loads, wherever the name still identifies the vendor (it resolves, it is the discovery placeholder or a Llama-family name, or it is an Ollama-style name:tag); other chats keep the old behavior. Manual models on non-Ollama APIs are not stamped, because their vendor is guessed from the host and an unrecognized local server counts as OpenAI. LLMMetadata.resolvePinnedModel resolves a pin within its vendor. An Ollama pin resolves only among Ollama models; when its model is gone the turn fails with an error instead of going to any other vendor or silently to a different local model. Other vendors keep their behavior: the pinned model, or the vendor's recommended model for a retired one. The rule is applied to chat turns (including turns from the phone), chat renaming, icon generation, the provider binding check, and the chat UI, which keeps showing the missing model on Ollama so a send uses it and fails clearly. QA finding F-380. 2026-10-24: Don't read a partial line as the zsh dotfile directory The Internet-Free installer asks zsh for its dotfile directory and matched the reply with ^My dotfiles go in(.*), treating an empty capture as $HOME. Expectations are also checked against partial lines, so on a slow link the reply could match as soon as My dotfiles go in arrived, and a user with ZDOTDIR set got $HOME: the script went to $HOME and .zshrc would have been amended there, which zsh never reads. The reply now ends in a marker the regex requires. QA finding F-325 (T-24). 2026-10-24: Detect the shell reading the offline installer's commands The Internet-Free shell integration installer decided which shell to install for from $SHELL. When the session runs a different shell, such as tcsh started from a zsh login shell, it typed zsh-only syntax into tcsh, which rejected it, and the installer waited forever. The discovery command now also reports the name of sh's parent process, which is the shell the commands are typed into, and uses it when it is a supported shell. It falls back to $SHELL when ps is unavailable or the shell runs under an interpreter (xonsh under python). The reply ends in a marker so a partially received line is not taken for the answer. QA finding F-260. 2026-10-24: Give settings.json the default mode when replacing a dangling symlink Installing the Claude Code hooks when ~/.claude/settings.json is a dangling symlink replaced the link with a regular file, as intended, but the atomic write carried the symlink's own mode over, so the new settings.json was 0755: executable and world-readable, where a newly created one is 0644. When the path is a dangling symlink, write a new file beside it and rename it over the link, so it gets the mode any new file gets. QA finding F-072. 2026-10-24: Accept an empty settings.json when uninstalling Claude Code hooks Uninstalling the Claude Code integration with an empty or whitespace-only ~/.claude/settings.json reported that the file couldn't be parsed as JSON and asked whether to continue, because JSONSerialization rejects empty input. Install already treats such a file as an empty object. It has no hooks to remove, so the strip now succeeds without touching it. QA finding F-365 (T-52). 2026-10-24: Keep hand-written triggers across a profile reload On a profile reload, PTYTriggerEvaluator reuses each unchanged trigger so it keeps its state, matching the profile's trigger dictionaries against the cached triggers' dictionaryValue. The profile side was only lightly normalized, so a hand-written trigger (in a Dynamic Profile, say) that omits keys dictionaryValue always writes, such as contentregex or matchType, never matched. Every reload rebuilt it and lost its state, and a partial-line trigger then fired a second time on the same line. Build the trigger from the profile dictionary and match on its own dictionaryValue, so both sides are normalized the same way. QA finding F-266 (T-39). 2026-10-24: Show a restored session status in the tab bar A session's status came back from a saved arrangement into the Session Status tool and session.status, but the tab bar showed no subtitle or dot for it. The tab caches the winning session's status and refreshes it only when a session reports a change or the tab's sessions change. Restoration adopted the status silently, and it can finish after the session has already joined its tab, so the tab never learned of it. Adopting a restored status now reports a change like any other, flagged as a restoration so PTYSession doesn't post a status-change notification for it. QA finding F-066. 2026-10-24: Send a plain it2 session set-var value as a JSON string The API takes a variable's value as JSON and ignores one that doesn't parse, so it2 session set-var user.x hello printed success and exited 0 while setting nothing; only a quoted '"hello"' worked. Send a value that already parses as JSON unchanged and encode anything else as a JSON string. Rebuilt the checked-in it2 binary with make paranoid-it2cli. QA finding F-067. 2026-10-24: Decode session name and tty in it2 session list it2 session list fetched session.name and session.tty as JSON-encoded variable values and only trimmed the surrounding double quotes, so any escape iTerm2 emitted survived into the output: every tty printed as \/dev\/ttys000, and a name containing a quote or backslash kept its escapes. Decode the values as JSON strings instead. Rebuilt the checked-in it2 binary with make paranoid-it2cli. QA findings F-318 and F-127. 2026-10-24: Label the Profiles window's icon-only split buttons The Profiles window's Split Horizontally / Split Vertically buttons became icon-only with no tooltip, and VoiceOver read the SF Symbol names (Square split one by two) instead of what the buttons do. Give both a localized tooltip and accessibility label, and switch them to Open in a New Window, Split while Option is held, when the buttons show the new-window icon and open the profiles in a new window. QA finding F-227. 2026-10-24: Fit the Tags toggle button title in pt-BR and Russian The Tags > / < Tags toggle under the profile lists is a fixed-width square button. The pt-BR Etiquetas > was clipped to Etiquet in Settings > Profiles and the Profiles window, and the Russian title that Settings sets in code ( >) was too wide as well. Use Tags > in pt-BR, which the Profiles window's code-set title already used, and > in Russian, which the XIB titles already used. A test measures every translation against the button's text area. QA finding F-232. 2026-10-24: Address the user formally in the German website permissions strings The German UI addresses the user with Sie, but the three new strings for the browser website permissions panel used the informal du forms (fragt iTerm2 dich, Gib eine Website ein). Rewrite them with Sie. QA finding F-362. 2026-10-24: Translate the OSC 7501 paragraph in the Session Status help The English Session Status help gained a Program Status Protocol (OSC 7501) paragraph, but no translation was updated, so the help in every other language lacked it and all five translations were left in needs_review. Add the paragraph in de, ja, pt-BR, ru and zh-Hans at the same place as in English and mark the translations reviewed. QA finding F-369. 2026-10-24: Use consistently in Japanese and localize (unknown) Japanese translations write user as almost everywhere, but the title preview's User sample (in both the code catalog and the Settings XIB), the OSC 7 disabled message and the Meta key warning used the short form . Switch them to . The SSH Integration file transfer description also inserted a literal (unknown) when the user name was missing; localize it. QA finding F-368. 2026-10-24: Use the custom headers term consistently in Chinese In Simplified Chinese the no API key hint told users to use , but the section it points to is labeled and every other AI hint says . Use the same term in the hint. QA finding F-367. 2026-10-24: Localize the AI chat watch consent body and timer bubble The body of the Allow repeated screen reads? prompt and the register_timer chat bubble (Setting a timer to fire in 5min: ) were English literals assembled from fragments, so they stayed English in a translated UI even though the prompt's title and buttons were translated. Give each variant its own complete localized sentence: the consent body with and without a workgroup name, and the timer bubble for a duration, an absolute time or neither, each with and without a note. The duration still comes from the localized compact date formatter. QA finding F-280. 2026-10-24: Localize the browser HTTP authentication prompt heading The browser's basic-auth prompt heading (The website host requires a user name and password [for realm].) and the Could not save the password for host toast were English literals, while the prompt's fields and buttons were translated. Wrap them in String(localized:) with positional placeholders and translate them. QA finding F-271. 2026-10-24: Localize the Set Profile Setting trigger's picker and row text The Set Profile Setting trigger action's picker placeholder (Select Setting), its On/Off popup, and the trigger list row text were English literals. The row reused the English debug description, so an unconfigured trigger read Set Profile Setting. Localize the placeholder and On/Off items, and give the row its own localized text: a complete sentence per value (Set X to On / to Off) so translators control word order, and no parameter text at all when no setting has been chosen, so the row shows just the action title. The English description stays for debug logging. QA finding F-270. 2026-10-24: Localize the AI-generated name title component Settings > Profiles > General > Title added its AI-generated name item with a hard-coded English title, and the title preview used the literal AI, so both stayed English (pt-BR showed AI where the rest of the UI says IA). Wrap both in NSLocalizedStringWithDefaultValue and translate them, using the same name the aiGeneratedTabTitles advanced setting tells the user to add. QA finding F-340. 2026-10-24: Localize the workgroup session Mode popup titles iTermWorkgroupSessionMode.localizedTitle used Swift NSLocalizedString, which the extraction step (Swift stringsdata) does not pick up, so Regular, Code Review and Diff never reached Localizable.xcstrings and the Mode popup in Settings > Arrangements > Workgroups was English in every language. Switch to String(localized:) with symbolic keys and translate them, reusing the existing workgroup preset terms. QA finding F-311. 2026-10-24: Translate the Focus Follows Mouse even to views option The Settings > Pointer sub-option even to views like Search or Composer (tXe-jL-3Kg) had no entry in PreferencePanel.xcstrings, so it was English in every language while the rows around it were translated. Add the entry with de, ja, pt-BR, ru and zh-Hans translations matching the neighboring even if options and the existing Composer wording. QA finding F-310. 2026-10-24: Translate the menu bar icon settings row Settings > Appearance > General's Menu bar icon: label, Show menu bar icon checkbox and the text field placeholder were missing from PreferencePanel.xcstrings (a command-line build does not run the IDE's XIB string extraction), so they were English in every language. Add the three entries with de, ja, pt-BR, ru and zh-Hans translations. QA findings F-262, F-304. 2026-10-24: Translate Lock Location, Rename Tab and the Tab Color label Window > Lock Location had no entry in MainMenu.xcstrings, so it was English in every language. The tab context menu's Rename Tab had an English-only catalog entry, and the Tab Color: label drawn by ColorsMenuItemView was a hard-coded literal. Add the menu item's catalog entry, localize the label, and translate all three into de, ja, pt-BR, ru and zh-Hans using the neighboring Lock Size / Lock Layout and tab terminology. QA findings F-022, F-042, F-152. 2026-10-24: Add QA VM provisioning doc Document the ~/bin vm-reserve/vm-return/vm-status workflow agents use to reserve a QA VM from the shared pool, including the PID-lifetime semantics, blocking with --wait/--timeout, and the pool config file. 2026-10-23: Hide the AI chat submit hint when a long placeholder would overlap it Co-Authored-By: Claude Opus 5.5 2026-10-23: Pick an AI chat @-mention by clicking the session While an @-mention is being composed in an orchestration chat, every session shows the same Select this Session overlay used by Link this session, so the user can click the one they mean instead of hunting through the picker. The overlay also has a Reveal Chat button that returns to the chat with the partially typed mention, switching tabs for a chat in a session's sidebar. The session buttons stay up while a sidebar chat's tab is not selected and come down when the mention is finished, dismissed with Escape, or the chat is hidden, closed, detached, or its host session terminates. SessionSelector now notifies session views whenever the top request changes, not only when the stack empties, so stacked link and mention requests don't leave stale overlays. The overlay is centered over the terminal area rather than over a side panel. Co-Authored-By: Claude Opus 5.5 2026-10-23: Make browser sessions work in iTerm2 Buddy Streaming a web browser session showed a black screen, and an AI chat that tried to run a command in a browser session linked as a terminal spun forever. Browser sessions now stream snapshots of the web view and are interactive from the phone. Mac: - Stream web view snapshots for browser sessions. - Deliver taps, scrolls, and keys from the phone to the page. - Tell the phone when a text field has focus, checking the focused element directly since the window is usually not key. - Draw a blinking insertion point into the frames when WebKit does not. - Back, forward, reload, stop, and load typed text (URL or search). - Report whether each listed session is a browser. - A browser session can never be linked as a chat's terminal (or the reverse); existing chats are repaired, including swapped links. - A remote command that fails to start is reported to the model and the user instead of leaving the turn hanging. Phone: - Tap to click, drag or flick to scroll, and pinch to zoom. - The keyboard follows text field focus, with a toolbar toggle. - Browser controls with an address field. - A globe icon for browser sessions in session lists. Protocol revision 15 adds the browser messages; minimumPeer stays 11. 2026-10-22: Send arrow keys and Tab an IME hands back after committing, not just Return and Backspace. Issue 13030 An input method can commit its composition and then hand the keystroke back as a command selector. Only deleteBackward:, its decomposing variant, and insertNewline: were sent to the terminal. Any other selector, such as moveLeft: for an arrow key or insertTab: for Tab, was dropped along with its key. Add insertTab:, insertBacktab:, moveLeft:, moveRight:, moveUp:, and moveDown: to the selectors that are sent. Changes from the original version of this commit, made in review: - It originally sent every selector except noop:. A key bound to several selectors (Option-Up is (moveBackward:, moveToBeginningOfParagraph:)) calls doCommandBySelector: once per selector, so the key reached the terminal twice. It also would have started sending keys nobody had checked, such as Ctrl-letters bound to editing selectors. An explicit list keeps the change to the keys that were tested. - The key is now sent at most once per keypress, even if a user's DefaultKeyBinding.dict binds a key to a listed selector more than once. - Added tests for a key bound to several selectors and for unlisted selectors. Co-Authored-By: Claude Opus 5.5 2026-10-22: Make tmux divider drags end where the window showed them (#796) When a divider in a tmux tab was dragged, the resize was sent as resize-pane to the first pane of the subview before it. tmux moves the divider of the nearest enclosing split of the same direction as seen from that pane, so with nested splits (three columns whose first column is itself split left/right) it moved the inner divider and the dragged one snapped back. Some dividers have no pane that can address them at all. Now the drag is sent as select-layout with a layout built from tmux's last layout, in which the two cells beside the divider get the sizes their panes were shown at, rounded to whole cells. Nested splits beside the divider share the change as the window previewed. Everything else keeps tmux's sizes. resize-pane, sent to a pane whose split owns the divider, is the fallback when no layout can be built (floating panes, a maximized pane, or a resize still waiting on tmux, since the layout would be stale). If neither works, the layout is fetched from tmux again. - The layout is quoted; unquoted, tmux parses its braces as a command block. - A moved-divider layout counts as an outstanding resize until tmux replies. If tmux rejects it, the layout is re-synced. - Commands that tmux should always be able to run can now be flagged to report a malformed-command error (a critical error in debug builds, an RLog otherwise) instead of being tolerated silently. - iTermTmuxLayoutBuilder placed nested splits at the wrong offsets. - The new controller methods do nothing after a detach. Based on a fix by breakerr, who found the nested-split case. Co-authored-by: breakerr 2026-10-21: Harden the x-man-page URL handler. Issue 5298. Validate man page names, apropos patterns, and sections against an explicit allowlist instead of the sanitizedCommand blacklist. The old code let a URL name a file path, which /usr/bin/man renders directly and whose .so directives it evals, and let an apropos pattern begin with a dash, which man.sh's second getopts pass reads as an option. An operand must now start with an ASCII letter, digit, or underscore and may contain only ASCII alphanumerics and . _ + : @ -. Sections must be ASCII alphanumeric. Empty operands get their own warning instead of the disallowed-character one. The unused sanitizedCommand: helper is removed and the validators are exposed as class methods with unit tests. Co-Authored-By: Claude Fable 5.1 2026-10-21: Fix vertically cropped Kitty images in the GPU renderer. Issue 13146. A direct Kitty placement's source frame is stored with a bottom-left origin, but the Metal renderer used it as a top-left texture rect, so a y=/h= crop sampled the mirrored band. Flip it first, as the unicode placeholder path already does. Adds unit tests and a manual repro script. Co-Authored-By: Claude Opus 5.5 2026-10-21: Evaluate trigger-local interpolated parameters without pausing A trigger parameter written as an interpolated string was always evaluated with the session's variable scope, which costs a paused side effect. Token execution then stops until the main queue flushes side effects, which takes up to a second for a session that isn't visible. tmux panes share TaskNotifier's reader thread, so a paused pane whose token queue fills blocks output in every session. Idempotent triggers like Make Hyperlink are rescanned constantly, so this happened often. Issue 13160. Parameters that refer only to matches[N] and line are now classified once, cached, and filled in from a template on the mutation queue with no pause. Everything else is evaluated as before, but performBlockWithScope: now uses an urgent paused side effect so background sessions wait for the foreground flush period instead of the background one. 2026-10-21: Fix the Make Hyperlink trigger's interpolated default parameter The interpolated default was https://\(match0), but no match0 variable exists. Trigger expressions get `matches` (an array) and `line`, so use https://\(matches[0]). Co-Authored-By: Claude Opus 5.5 2026-10-21: Fix tab bar corruption when unpinning a tab left of the pin boundary. Issue 13154 The reorder after a pin change and the pinned-zone clamp in moveTabAtIndex:toIndex: both computed the destination from the last pinned index in the current order. When the moving tab sat left of that boundary (unpinning any pinned tab but the rightmost, or mid-way through unpinning a group), the destination was one past the end, so PSMTabBarControl threw after removing the cell and its group chips, leaving the bar out of sync with the tabs. Count the other pinned tabs instead, guard against out-of-range destinations, unpin group members right to left so the group keeps its order, and add logging along the path. Co-Authored-By: Claude Opus 5.5 2026-10-21: Release note for the xonsh shell integration fix Co-Authored-By: Claude Opus 5.5 2026-10-21: Update shell integration for the xonsh installer fix The installers now put the xonsh script in rc.d, where xonsh loads it. The source line they wrote before never worked, so xonsh integration was installed but never loaded. Co-Authored-By: Claude Opus 5.5 2026-10-21: Release note for the v2 shell integration installer fix Co-Authored-By: Claude Opus 5.5 2026-10-21: Download the v2 shell integration installer The Download and Run Installer path fetched install_shell_integration.sh from the legacy iterm2.com/shell_integration/ URL, which serves the frozen v1 installer. That installer downloads the v1 scripts, which are older than the versions 3.7 expects, so the out-of-date warning came back after every upgrade. Fetch the installer from /v2/ instead. Issue 13155. Co-Authored-By: Claude Opus 5.5 2026-10-20: Fix the setting name in the release note for issue 13145. 2026-10-20: Make Hide Activity Indicator also hide the tab progress spinner. Issue 13145. OSC 9;4 progress drove the tab's activity indicator directly, bypassing the Hide Activity Indicator setting, which only gated the busy state. PSMTabBarControl now asks its delegate whether to show activity indicators at all, and the window re-pushes processing state when settings change so toggling the setting takes effect immediately. 2026-10-20: Honor disabled ligatures when not preferring speed to ligature support When the advanced setting preferSpeedToFullLigatureSupport is off, the legacy renderer shapes every run with CoreText but left the ligature level at the font's default even when ligatures were disabled. Fonts with a standard "fi" ligature drew it in one cell and left a gap. Set the ligature level to 0 when ligatures are disabled for the character's class. Issue 13133. Co-Authored-By: Claude Opus 5.5 2026-10-20: Keep the kitty keyboard protocol across XTMODKEYS requests. Issue 13138 CSI > Ps ; Pv m and CSI > Ps n used to zero the kitty keyboard flags and empty the push stack, so an app that pushed kitty flags and then asked for modifyOtherKeys (as Claude Code does) fell back to the modifyOtherKeys mapper, which never reports Command keys. kitty ignores these sequences and Ghostty keeps the kitty protocol in either order, so now they only record the modifyOtherKeys-style setting. Kitty flags already take precedence in the key mapper, and the recorded level takes effect once the app pops its flags. Full terminal resets still clear everything. Also fix an off-by-one in the CSI > Ps n bounds check. 2026-10-20: Remove a stray conflict marker from the release notes Co-Authored-By: Claude Opus 5.5 2026-10-20: Add release note for it2dl bash path fix Co-Authored-By: Claude Opus 5.5 2026-10-20: Make it2dl find bash via env so it works where bash is not in /bin Bump the iTerm2-shell-integration submodule and recopy the utilities. Co-Authored-By: Claude Opus 5.5 2026-10-19: Fix Caps Lock reports when it switches input sources. Issue 13136 With the Kitty keyboard protocol on and macOS set to switch input sources with Caps Lock, macOS changes the lock state with synthetic flags-changed events that have keyCode 255. These had no key number, so they were reported as malformed sequences like CSI 1:3 u, which some apps read as Control-C. Treat a lock-state change from an unknown key code as Caps Lock. When the setting is on, report nothing for a tap that switches input sources, and report a press and release when a hold or tap leaves the lock state changed. Never send a flags-changed report without a key number. Add unit tests for each recorded event sequence, harness steps for them, and a script that prints the key reports a terminal sends. 2026-10-19: Recycle Snippets tool cells and reset their icons on reuse The cell views never set their own identifier, so makeViewWithIdentifier: never recycled them. Set it so it does. Folders and snippets share a reuse identifier, so also set the icon and tint each time a cell is used rather than only when it is created; otherwise a recycled cell shows the other kind's icon. 2026-10-19: Keep Auto Layout out of the terminal window. Issue 13118. With the Minimal or Compact theme and the Snippets tool enabled in the toolbelt, a window could be made wider but not narrower. The inline AI chat panel caused the same thing. Any constraint in a terminal window engages the layout engine for the whole window. AppKit then turns every manually laid out view's autoresizing mask into constraints and clamps live resizes to the content view's fitting size. Several hidden views keep stale frames whose fixed margins become a minimum width: the hidden tab bar backing (CGRectZero since the layout calculator refactor, so the minimum was the current width and the window ratcheted), and the hidden toolbelt and fake title label (a fixed floor when the tab bar is shown). The Snippets cells pinned their text field and icon with constraints. Lay out IconTableCellView by hand in layout(), and use it for the plain cell too. ChatInputView turned off translatesAutoresizingMaskIntoConstraints on its subviews even though it lays them out by hand; that gave its buttons and hint label content-size constraints. Stop doing that. An audit of code, xibs, the browser plugin and the inline chat found no other constraints in the terminal window. 2026-10-19: Size tmux panes with each pane's own right gutter When applying a tmux layout, every leaf was sized with the window profile's right extra and no session, so right-gutter panels (clippings, inline chat) and per-pane adjacent timestamps were ignored. The pane showing them was too narrow for its grid, the panel covered the ends of its lines, and the window was left with an empty strip on the right. Annotate each leaf with its session's desiredRightExtra alongside the toolbar flag and use it in the geometry pass, falling back to the profile only when the pane has no session yet. 2026-10-17: Fix a main-thread hang waiting on the Metal layer lock. Issue 13139 iTermMetalLayerBox holds its lock across CAMetalLayer.nextDrawable() on the get-drawable queue, and the layer's drawable timeout was disabled, so a fetch could hold the lock forever when the drawable pool drained (for example under heavy memory pressure). The main thread then blocked on the same lock in per-frame property accesses and never recovered. - Re-enable the layer's drawable timeout so nextDrawable() holds the lock for at most about a second. A fetch that completes with a nil drawable ends the frame instead of retrying, so the retry loop can't spin. - Skip the lock in the presentsWithTransaction setter, which runs every frame, when the cached value already matches. - Move the zero-size check into iTermMetalLayerBox.nextDrawable() so it runs under the lock. It throws, the fetch rejects its promise, and the retry loop no longer asserts that every rejection is a timeout or reuses a failed promise. - Add TimedMutex and use it for the layer lock. Setting the drawable size defers while our own fetch is outstanding; fetchDrawable waits on that fetch (bounded by the drawable timeout) and then applies the size, and won't hand out a drawable until it has. Main-thread waiting per frame stays bounded by the drawable timeout. - Add iTermPromiseErrorDomain instead of repeating the literal. 2026-10-16: Push relay: second instance with peer replication; apps fail over The push relay was a single instance, so a VPS outage at its host lost every push for hours. Add a second instance (push2.iterm2.com) and client failover, with no load balancer, so nothing is billed per request. Apps: CompanionPushRelay lists both hosts. The phone registers with the first that answers; the Mac pushes through the first that delivers. Both share one loop (tryInOrder) and move on unless the relay itself says the request is bad (400), or, for the Mac, rate limited (429), the APNs rejection is one any relay would get, or the secret was revoked ("bad secret"). After a failover the Mac prefers the host that worked for 5 minutes. Relays (host/peer.js, Node host only; src/worker.js unchanged): each relay queues every registration in a SQLite outbox before replying to the phone and forwards it to its peer's /peer/register, authenticated with a shared secret. A single drain loop sends live rows before backfill, backs off failing rows, and gives up a pass when the peer looks down. The receiver keeps the later registeredAt (relies on NTP) and writes atomically; a phone registration is stamped after anything the relay already held. Each relay resends everything at startup, on SIGUSR2, and when the peer's instance or boot id changes. Shared APNs 4xx rejections go out as 422 so Cloudflare passes the reason through. SQLite now runs synchronous=FULL. Off unless PUSH_PEER_URL and PUSH_PEER_SECRET are set. See "Redundancy (two instances)" in Companion/PushRelay/ops/DEPLOY.md. 2026-10-16: Make the live refusal scenario opt-in and soften its prompt The refusal scenario sent a bank phishing request to every model on every live sweep, and OpenAI flagged the account for suspected fraud. It now runs only when ITERM2_AI_LIVE_REFUSAL is set. "missing" sends the prompt only to models that lack a committed fixture; "all" refreshes every model. Cassette replay still runs it offline without the opt-in. This replaces ITERM2_AI_LIVE_REFRESH_REFUSAL_FIXTURES. The prompt is now a low-severity request with no fraud or cyber framing. Co-Authored-By: Claude Opus 5.5 2026-10-16: Keep launch-time prompts out of test hosts A modal window at launch blocks button and menu actions in the tests that follow, failing dozens of unrelated tests. Two could appear: - The crash reporter, whenever iTerm2 had crashed since it last reported. Test hosts now skip it. - Sparkle's prompt asking whether to check for updates automatically. Sparkle takes its suite from the -suite argument, so it uses the shared base suite rather than the test host's copy, and prompts when that lacks its settings. Test hosts now give Sparkle a delegate that never prompts or checks. 2026-10-16: Report archive failures and fix more values that broke saved files Session archives and window arrangements saved to a file are property lists. Follow-ups to the hyperlink fix for issue 13143: - propertyListData returned the empty contents of a temp file when serialization failed, so archiving wrote an empty archive and reported success. It now returns nil, and archiving shows its failure notification. The output on success is unchanged. - Bidi info stored ranges as NSValue, which broke saving lines with right-to-left text. Ranges are now [location, length] pairs. Older saves still load, and malformed or overflowing ranges are dropped instead of crashing. - Automatic profile switching stored NSNull for missing values. Missing values are now omitted, saved entries are type-checked on load, entries without an original profile are dropped, and popping the stack can no longer loop forever when an original profile is nil. Issue 13143 2026-10-16: Isolate test hosts' settings and stop hung test runs Parallel test hosts shared one settings domain, Application Support folder, and config folder. A setting one test changed was visible to the other workers, only one worker could open the chat database, and a worker that crashed or was killed before its teardown left the setting changed for every later run. A test host launched with -suite NAME now uses its own suite, NAME.-, seeded from NAME, which is never written. State left by test hosts that are no longer running is removed at launch. run_tests.expect now treats 300 seconds without output as a hang. It saves each test host's process state and stacks to tmp/test-stall-*.txt, prints which test each host was in, and kills the run. The live harness scripts allow 30 minutes because a single live test can wait minutes. RUN_TESTS_STALL_SECONDS overrides the limit, and 0 turns it off. 2026-10-16: Don't crash highlighting a composer that has no delegate iTermMinimalComposerViewController forwards the syntax highlighter request to its own delegate and returns nil when there isn't one, but ComposerTextViewDelegate declared the result non-optional, so Swift crashed calling highlight on it. This happened in the Cockpit composer tests and could happen when a rate-limited highlight fires after a composer's delegate is gone. Make the result optional and skip highlighting when it's nil. 2026-10-16: Add a non-interactive stable release script release_stable_noninteractive.sh is the unattended counterpart of release_stable.sh, modeled on release_beta_noninteractive.sh. It reads secrets from the keychain, takes release notes from docs/notes-.txt, and runs all cheap checks before building. Stable-specific behavior: - Releases from master or a release branch, as long as HEAD matches origin. - Writes full_changes.txt, the stable changelog, .htaccess, and both final appcasts. - Publishes the shell integration commit this release ships to the website, after checking that the submodule matches HEAD and is pushed. - Fails after the build if it modified tracked files, which means the string catalog and translations are out of date. Co-Authored-By: Claude Opus 5.5 2026-10-16: Sync the website's shell integration submodule on stable release release_stable.sh now checks out the iTerm2-shell-integration commit this release ships in ~/iterm2-website and republishes its scripts with copy-shell-integration.sh, so the website no longer drifts behind the app. Before prompting for passwords it verifies that the local submodule is clean, matches the commit recorded in HEAD, and is pushed to origin. The website is only modified after the build succeeds. Co-Authored-By: Claude Opus 5.5 2026-10-16: PushRelay: recover from dead APNs connections; add APNs health stats A long-idle HTTP/2 session to Apple could die silently (no TCP keepalive), after which every push timed out until a restart, and nothing was logged because the worker turns the throw into a 500. - apns.js: discard + evict the session on a request timeout; replace sessions idle > 5 min; open at most one new connection per second per APNs host so an outage can't turn every push into a connect attempt; telemetry hook. - server.js: log any 5xx on /push and /push/mutable. - metrics: push_failed, apns_rejected{reason}, APNs latency histogram, connection health counters, session gauges, last-delivery timestamp. Co-Authored-By: Claude Sonnet 5.5 2026-10-16: Fix release script checks that failed under pipefail grep -q exits on the first match, which can SIGPIPE the producer and make the pipeline fail even though the match succeeded. 2026-10-16: Sync Password Manager export strings into the string catalog 2026-10-16: Add Password Manager export to 3.7.7beta1 release notes 2026-10-16: Add a non-interactive beta release script tools/release_beta_noninteractive.sh builds, notarizes, signs, and publishes a beta without prompting. Secrets come from the login keychain: the Sparkle EdDSA key as a generic password, notarization via a notarytool keychain profile, and the gpg passphrase via pinentry-mac. Release notes come from the committed docs/notes-.txt instead of vi edits. Cheap checks run before the build, the website is published before the tag is pushed, and only the release's own website files are committed. A rebuild replaces the existing zip and moves the tag. --no-publish stops before tagging, committing, or pushing. 2026-10-16: Add release notes for 3.7.7beta1 2026-10-16: Export and import Password Manager passwords. Issue 7471. Passwords saved with the Keychain password manager can be exported to a passphrase-encrypted .iterm2passwords file and imported on another Mac, from the password manager's menu, by opening the file, or as an optional part of Export/Import All Settings and Data. - SignedArchive gains passphrase encryption: AES-256-GCM with a PBKDF2 key (NFC-normalized passphrase, bounded iteration count), the header and metadata bound as AAD. The extract tool can decrypt. - Import asks about each conflict, with Always/Never Replace, skips identical passwords, and reports partial progress on failure. - Export and import always authenticate first. - SSKeychain reports an error for an empty or non-UTF-8 password instead of returning nil without one. - The Makefile builds non-Development configurations into their own intermediates folders so switching doesn't leave a stale PCH, and adds run-deployment. 2026-10-16: Bump Companion marketing version to 1.3 Co-Authored-By: Claude Opus 5.5 2026-10-16: Fix the Python API docs build Build in a private venv with sphinx-autodoc-annotation and the iterm2 package's runtime dependencies instead of relying on whichever sphinx-build is on the PATH. Update intersphinx_mapping to the format current Sphinx requires, exclude the venv from the docs sources, and regenerate menu_ids.rst. Co-Authored-By: Claude Opus 5.5 2026-10-16: Re-sign example .its files with the current Developer ID Application certificate Co-Authored-By: Claude Opus 5.5 2026-10-16: Fix saving arrangements and archives that contain hyperlinks Window arrangements saved to a file with contents and session archives are written as property lists, which only allow string dictionary keys. iTermExternalAttributeIndex encoded hyperlinks, underline colors, and similar per-cell attributes in a dictionary keyed by NSNumber, so serialization failed. The arrangement file was silently never written and the session archive was empty. Encode the index with string keys and accept both string and legacy NSNumber keys when decoding, so existing restorable state still loads. Show an error when writing an arrangement file fails instead of discarding it. Issue 13143 2026-10-16: Strip HTML tags from the profile name in window titles With Support basic HTML tags in tab titles on, the window title strips tags from every title source except the profile name, so a profile named Work with the Profile Name or Profile & Session Name component showed the literal tags in the native window title. Co-Authored-By: Claude Opus 5.5 2026-10-16: Render HTML title tags in pane title bars With Support basic HTML tags in tab titles on, tabs render , , and , but the per-pane title bar set its label from the plain string, so the tags showed up literally. It now runs the title through the same parser the tab bar uses before applying PUA fonts. The native window title is plain text, so a window title shows the text without the tags. That already held for titles from the session name, but a window name set by a program (OSC 0/2) or by tmux was returned verbatim, tags included. Every window-title source is now stripped. 2026-10-15: Let KeePassXC remember browser basic-auth passwords The browser's basic-auth store names each remembered credential after its protection space, such as http://localhost:8022 (Realm). The KeePassXC adapter uses the account name as the keepassxc-cli entry path, and keepassxc-cli treats every / as a group separator with no way to escape it, so add-account always failed and Remember this password showed Could not save the password. Any account name containing / failed the same way, including a rename in the password manager window, which deleted the entry before the re-add failed. The KeePassXC data source now stores / in titles as U+2215 DIVISION SLASH, which looks the same in KeePassXC, and maps it back when listing so names still match. Entries are still addressed by the adapter's own entry path. Other adapters receive account names unchanged. The fix is on the iTerm2 side so the signed adapter binary does not need to be rebuilt. QA finding F-342. 2026-10-15: Keep text view contents out of the undo-safety debug log With debug logging on, the undo-safety scrub that runs when an undo-enabled text view leaves its window logged the NSTextStorage with %@. Its description is the full text, and the text view is often the field editor of a secure text field, so passwords typed into the password manager or a KeePassXC prompt were written verbatim to the debug log. The log line is now built by iTermUndoSafetyScrubLogMessage, which describes the text storage by class, address and length and the text view by class and address, never by contents. QA finding F-341. 2026-10-15: Don't offer a deleted directory from the status bar proxy icon The Current Directory status bar component's folder icon could still be dragged after its directory was deleted. The container computed the icon's URL, including the existence check, only when it updated the component, and the icon kept that URL. Deleting the directory from elsewhere changed nothing the container watched, so a drag still vended a URL to a folder that no longer existed. The icon now asks for its URL through a provider when a drag begins, and the component checks then that the path is a local directory that exists. The check moved into a small helper so it can be tested, and the stat on every status bar update is gone. QA finding F-214. 2026-10-15: Open the status bar component menu when its proxy icon is clicked Since the Current Directory status bar component's icon became a draggable proxy icon, a plain click on it did nothing. The container's click gesture recognizer delays mouse events until it fails. On a click it recognized, so the icon never got the mouse-down and mouse-up it uses to tell a click from a drag, and clickRecognized: deliberately ignored clicks on the icon to avoid opening the menu twice. Drags still worked because the recognizer fails on movement and then delivers the delayed events. Hostname and Username share the same proxy icon class and had the same problem. Make the container the recognizer's delegate and decline every event of a gesture whose mouse-down lands on the proxy icon. The decision is latched at mouse-down so one press and release is never split between the icon and the recognizer. The icon then handles the click itself, opening the same menu as clicking the text, and dragging still works. QA findings F-213 and F-105. 2026-10-15: Disable session-only composer actions in the Cockpit The Cockpit's docked composer has no terminal session behind it, but Edit > Paste Special > Advanced Paste (Opt-Cmd-V) and Session > Open Command History (Shift-Cmd-;) stayed enabled there: Advanced Paste did nothing and command history opened an empty popup outside the composer. The composer's help popover also listed them, along with AI lookup (Cmd-Y), which the Cockpit already disables. The composer now has a sessionless mode, which the Cockpit turns on. It disables those two menu items and leaves them and AI lookup out of the help popover. QA finding F-377. 2026-10-15: Keep chips' attachment characters out of Cockpit commands An @-mention chip in the Cockpit composer is an attachment, which is the character U+FFFC in plain text. Copying text with a chip put U+FFFC on the pasteboard, and since the composer pastes plain text, pasting it back dropped the chip but kept the invisible character. Shift-Return then sent it to the shell as part of the command ("command not found"), because only a selection send stripped U+FFFC, not a whole-field send. A send now never includes U+FFFC. With plain-text paste forced (the Cockpit), copying removes it from the copied text, including a copy with several cursors, and pasting text that contains it inserts the text without it as its own undo step. QA finding F-376. 2026-10-15: Put @-mention chip edges on whole points from the baseline After 87c573476 the chip image's top and bottom sat a fraction of a point from the text baseline (for example 11.6 above and 2.53 below for the 12 point monospaced system font). Plain text's baseline lands on the pixel grid, and the chip's fractional edges were rounded to it when drawn, so on a 2x display the name inside the chip sat one device pixel (0.5 point) below the adjacent typed text. The chip's ascent and descent are now rounded outward to whole points when the line box has room for it, which keeps the name on the text's baseline at 1x and 2x. When the line box isn't a whole number of points and rounding outward would make the line taller, the exact edge is kept so nothing is clipped. QA finding F-330. 2026-10-15: Draw the Cockpit composer's placeholder once ComposerTextView named its placeholder property placeholderString, which overrides NSTextView's own (private) placeholder property. AppKit then drew a wrapped copy of the placeholder whenever the field was empty, on top of the composer's own single line. When the text fit on one line the two copies coincided, so it showed up only for a long placeholder in a narrow Cockpit, such as the Brazilian Portuguese one at 380 points. The property is now composerPlaceholderString, and the composer's own placeholder ends in an ellipsis when it doesn't fit instead of being cut off at the edge. QA finding F-263. 2026-10-15: Keep the floating Cockpit panel from covering sheets The Cockpit panel floats above iTerm2's windows. iTerm2's warnings for a terminal window, such as the Broadcast Input confirmation, are sheets on that window, at its normal level, so the Cockpit covered them and hid their buttons until the user moved the panel. While another window shows a sheet, the panel now drops to the normal level just behind that window, and it floats again once no window has a sheet up (including when a window closes with its sheet still showing). App-modal alerts already sit above floating panels. QA finding F-249. 2026-10-15: Skip closed targets when sending from the Cockpit When a session chipped in the Cockpit composer had its tab closed, Shift-Return refused the whole send with "Unknown target @ptys_..." (the session's internal id), so the targets that still existed got nothing. A chip whose session, tab, or window no longer exists is now skipped: the command goes to the targets that remain, and the status line names the skipped ones by their chip names. If every target is gone, nothing is sent and the status names them. QA finding F-242. 2026-10-15: Close the Cockpit's session picker when the caret leaves the @-run The Cockpit composer drove its @-mention picker from the first word-start @ anywhere in the text, whether or not the caret was in it. After typing "email me @ noon" the bare @ still counted, so the picker stayed open on every keystroke and Return committed a chip in place of the @ instead of inserting a newline. Esc hid the picker only until the next keystroke. Only an @-run that contains the caret now drives the picker. Esc remembers the run it dismissed and keeps the picker closed until the caret leaves that run, and a key routed to an open picker first checks that the caret is still in the run (a click doesn't change the text). QA finding F-240. 2026-10-15: Keep the stoplight hotbox off a browser's navigation bar In a Compact or Minimal window with no tab bar at the top, pointing at the top-left corner reveals the window buttons unless a clickable decoration lies beneath them, in which case only a chevron toggle appears. The check knew about the session toolbar, title bar and status bar but not a browser session's navigation bar, so the window buttons covered the back, forward and reload buttons and swallowed clicks meant for them. The browser view controller now reports its visible toolbar as a clickable decoration, and SessionView includes it in the check. QA finding F-339. 2026-10-15: Update a browser tab's minimal theme tint when the page's color arrives In the minimal theme a browser tab's tint lagged one page load behind: it showed the previous page's background color. The page color was read only once, in didFinish, but WebKit updates underPageBackgroundColor a moment after the load finishes, so didFinish still saw the old page's color and the new one was never delivered until the next load. Observe underPageBackgroundColor with KVO and forward changes once the load has settled and the web view is no longer loading, so WebKit's transient values during a load still do not flip the tint. Repeated colors are not forwarded, so a stale value at didFinish does not use up the session's rate limit and delay the real color. QA finding F-039. 2026-10-15: Keep long SSH integration framer commands from being cut in two Conductor encoded a framer command by base64-encoding each argument, joining them with newlines, and then splitting the whole string into 128-character lines that end in a backslash continuation. When the newline between two arguments fell exactly on a 128-character boundary, the next line began with that newline, so an empty line went over the wire. framer.py treats an empty line as the end of a command, so it ran the first half with a mangled last argument and then the rest as a separate command. Commands with several long arguments, such as login, save, and file suggestions, could hit it. Split each argument into continuation lines on its own instead, so the only empty line is the one that ends the command. framer.py rejoins continuation lines regardless of where they were split, so existing remotes decode the new output unchanged. The pre-framer conductor.sh encoding is unchanged. Found while adding the F-061 regression test. Tangential finding T-74. 2026-10-15: Stop Engage AI from crashing in the Cockpit composer Edit > Engage Artificial Intelligence (Cmd-Y) in the Cockpit's docked composer crashed iTerm2. The Cockpit composer belongs to no session, so its variable scope is nil. The composer hides its AI controls there, but the menu item and key equivalent still reached performNaturalLanguageQuery:, which handed the nil scope to AITermControllerObjC and on to AIPromptTemplateEvaluator, whose force-cast of the scope's copy trapped. AIPromptTemplateEvaluator now takes an optional scope and falls back to an empty one, and the composer's scope and AITermControllerObjC's scope parameter are declared nullable. The large composer now disables Engage AI where its AI controls are force-hidden (only the Cockpit), and the action does nothing there. Ordinary composers keep the item enabled so the generative AI lockout explanation (F-115) still appears. QA finding F-370 (resolves T-45). 2026-10-15: Write the nushell shell integration script in order and verify it The Internet-Free installer for nushell launched its helper sh and then typed the next command as soon as any output appeared, which the echo of the launch line itself provided. nushell's reedline reads typeahead in bulk, so commands meant for sh were consumed by nushell, a nested helper sh started late, mkdir landed inside the heredoc, and the autoload directory was never created. The step still reported that it wrote the script because it only waited for a prompt. For nushell, wait for the helper sh's own prompt before typing anything else (this also covers the utilities step). The script step now runs in the first helper sh with no nested one: it sets and creates the autoload directory and prints a sentinel, opens the heredoc only after that, and sends the script, terminator and a check in one piece. The check prints whether cat succeeded and the file is non-empty, and the step advances only when the file was written. Otherwise it shows an error and lets the user try again. Adds nushell tests with a fake terminal that loses typeahead to nushell the way reedline does, and updates the discovery command the existing installer tests expect. QA finding F-363. 2026-10-15: Don't ask to copy settings at quit just because a migration ran With settings loaded from a custom folder and manual saving, quitting asks to copy settings to the folder whenever local prefs differ from the folder's plist. One-time launch migrations of synced keys (AlternateScreenBidi=NO becoming DisableBidiInAlternateScreen=YES, and a saved session status priority list gaining done and error) changed local prefs while the folder kept the old values, so every quit prompted. Choosing Lose Changes did not help because the migration ran again on the reloaded folder prefs. Migrations of synced keys now run through +[iTermUserDefaults migrateKeys:inUserDefaults:block:], which reports the values they changed. iTermRemotePreferences applies those changes to its copy of the folder's prefs (only for keys whose folder value is the one the migration started from) and, in manual-save mode, compares local prefs against that. The folder is not rewritten for a migration alone; the migration just runs again next launch. Automatic saving keeps its old behavior of writing the migration to the folder once. QA findings F-216 and F-069. 2026-10-15: Truncate the restorable state WAL after housekeeping and at quit The saved-state database runs in WAL mode. Launch housekeeping ran a passive checkpoint and then VACUUM, which writes a full copy of the database into the WAL. Nothing ever truncated it, and the connection is never closed at quit, so restorable-state.sqlite-wal stayed one to two times the database size and was left on disk after a clean quit. It grows with scrollback. Housekeeping now runs wal_checkpoint(TRUNCATE) after VACUUM. The synchronous save at quit also queues a TRUNCATE checkpoint on the database thread. It runs asynchronously so the main thread is not blocked; if the app exits first, the abandoned checkpoint is harmless and the next launch's housekeeping truncates the WAL. QA finding F-221. 2026-10-15: Add regression tests for focus in iTermWarning text prompts QA finding F-018 suspected that text-field and password prompts built with iTermWarning lose keyboard focus because they rely only on initialFirstResponder. Runtime QA (F-030, F-031, F-034) did not reproduce it: typing without clicking landed in the field for app-modal alerts and sheets alike, including the scp password prompt. The code agrees: every path that shows a warning builds the alert with makeAlert, which hands the field to the alert window's initialFirstResponder, and AppKit focuses it when the alert is shown. No code change is needed. These tests pin that mechanism for a plain text field, a secure field (as in the scp prompt), and a field chosen from a container of several. They fail if makeAlert stops setting the window's initial first responder. QA finding F-018. 2026-10-15: Add a regression test that keystrokes typed during a paste stay after it over SSH integration QA saw a stray "~" command run after pasting into an it2ssh session and pressing Return, and suspected the Return was being inserted inside the paste's bracketed-paste terminator. iTerm2 does not reorder this input. PTYTextView holds key-downs while the paste helper is pasting and replays them after the last chunk is written; in an SSH integration session both paste chunks and keystrokes go through Conductor.sendKeys, whose queue is first in, first out and whose pipelining only appends; and framer.py runs one command at a time, writing each payload to the pty before reading the next. The symptom comes from zsh instead. Its bracketed-paste reader gives up when the next byte takes longer than KEYTIMEOUT (0.4 s by default) to arrive. If the final "~" of the closing ESC[201~ arrives late, zsh ends the paste early, inserts the "~" as a typed character, and Return runs it as a second command. A local pty experiment reproduces exactly this with a 0.6 s gap before the "~" and nothing else. On a loaded remote, the shell's pty can fill while zsh reads, which delays the last bytes. The tests decode what the conductor writes the way framer.py does and check that the remote receives the whole paste and then Return: for every order in which the remote's replies can arrive relative to the writes (both with and without command output, which moves the conductor into its executing state), for keystrokes queued behind a command that is still in flight, and end to end from the paste helper with Return pressed mid-paste. There is no timing dependence: timers are fired by hand and replies are delivered explicitly. QA finding F-061. 2026-10-15: Keep the space between LTR and RTL text when copying part of a bidi line With right-to-left support on, a character drag is decomposed into one logical subselection per contiguous logical run. Dragging over the start of L4 end4 and part of the Hebrew yields the runs L4 and . Copy extracted each run separately with trim whitespace on copy on, so the space after L4 was trimmed and the words fused: L4 instead of L4 . Mark a decomposed run that is followed by another run on the same line (precedesSameLineBidiPiece, kept across copies and resizes). The selection extractor tells the text extractor to keep the trailing whitespace at the very end of such a range while still trimming before each hard newline inside it. QA finding F-321. 2026-10-15: Never send a Llama (Local) chat to the cloud default vendor Picking Llama (Local) in an AI chat while no Ollama model was discovered pinned the chat to the discovery placeholder model. At send time LLMMetadata.model(named:) did not recognize the placeholder, so AIConversation used no provider override and the turn went to the global default vendor, e.g. OpenAI: it opened that vendor's key sheet or sent the message to api.openai.com while the picker still showed Llama (Local). The placeholder now resolves to the Ollama vendor's recommended local model once one is discovered. Until then, and for any unresolved Llama-family pin, the turn fails before any request or key sheet with an error asking the user to start Ollama. Chat renaming and icon generation, which send the chat's content and title, now run on the turn's model instead of the global default. OllamaModelCache gains a test hook so the new tests never reach a real Ollama server or have a fetch overwrite their cache state. QA finding F-283. 2026-10-15: Don't act on a browser prompt answered after the script timed out load_url and browser_eval_js ask the user before acting, and the prompt never times out, but the script's call does: the Python API used the default 30 second invoke timeout. A user who took longer to answer got an RPCException: Timeout in the script while the prompt stayed up, and clicking OK afterward still loaded the URL or ran the JavaScript, after the script was told the call failed. A script that retried would act twice. The server-side timeout in iTermScriptFunctionCall dropped the method's late completion, but the method never learned it had been abandoned. Each call with a timeout now carries an iTermScriptCallCancellation that is cancelled when the timer fires. iTermBuiltInMethod injects it into an argument named cancellation, the way it injects connectionKey, without adding it to the public signature. load_url and browser_eval_js check it when the prompt is answered (and browser_eval_js before showing it) and do nothing if the script stopped waiting. Session.async_eval_javascript and Session.async_load_url take an optional timeout that defaults to ten minutes, long enough to read the prompt. QA finding F-360. 2026-10-15: Validate and clamp OSC 1337 SetWindowFrame SetWindowFrame=x;y;width;height parsed each field with doubleValue and passed the result straight to -setFrame:display:. Non-numeric fields became 0, and nan, zero, negative or huge values made the window 0x0 (so it vanished) or put it tens of thousands of points off screen. Any output printed to the terminal could do this when session-initiated window resizing is allowed. Parse strictly: the payload must have exactly four fields, each a finite number, or the sequence is ignored. When applying the frame, clamp its size between the window's minimum size (the largest tab minimum plus window decorations, as live resize does) and the union of the screens' visible frames, and move it onto the nearest screen unless its top edge and at least 40 points of it are already on one. QA finding F-059. 2026-10-15: Return focus to the terminal when a focused session note is hidden When a script cleared or hid the session note through the Python API while the user was typing in it, the note view was removed from the window and AppKit left the window itself as first responder. Every keystroke after that was dropped until the user clicked the terminal. Toggling the note closed with Edit Session Note while it had focus did the same. -[SessionView hideSessionNote] now checks whether keyboard focus is inside the note before removing it and, if so, gives focus back to the session, the same way collapsing a focused note already does. Hiding an emptied note when it loses focus is unchanged, since focus is already moving to wherever the user put it. QA finding F-308. 2026-10-15: Never put a native session into a tmux tab as a split pane With a split new-instance open style (NewInstanceOpenStyle 2 or 3) and a tmux integration tab current, opening a file such as a .command added a native split to the tmux tab. The next %layout-change terminated it, so the open was silently lost (before 35a475674 it took over tmux pane %0 instead). Open URL in a split pane in a tmux tab made a tmux shell pane and dropped the URL. PseudoTerminal now has canInstallNativeSplitPane, which refuses tmux tabs. When it refuses, the session launcher resolves split styles to a new tab (and reports the tab object type), and Open URL falls back to its existing new tab path via canOpenURLInSplitPaneOfTerminal:, the same fallback floating panes use. QA finding F-122 (and F-124). 2026-10-14: Add a regression test for tmux detach releasing Prevent Sleep Detaching from tmux while the windows stay open marks each tmux pane's session exited ("tmux detached"). QA saw those panes still counted as Prevent Sleep requesters, keeping the idle sleep assertion held. That build predated 81dafd414, which made PTYSession post PTYSessionExitedDidChange whenever its exited flag flips; tmuxDidDisconnect goes through setExited:YES, so the coordinator already recomputes and drops the detached panes on master. A reattach builds new sessions (closeAllPanes forgets the old panes), which PTYSessionCreated covers. Add a test that calls the real tmux disconnect handler on two real sessions in a real window with Prevent Sleep on and windows kept open after detach, with the coordinator using the real session enumeration, and checks the count drops 2, 1, 0 and the assertion is released. It fails with the count stuck at 2 if the exited-change post is removed. liveRequesterGUIDs becomes internal so the test can use it. QA finding F-212. (cherry picked from commit a578625277b8e3b76108c79e42f86664be246bb7) (cherry picked from commit 246fca93c8c3d6f07bacf62955d429688bb90521) 2026-10-14: Free each AI request's URLSession, delegate and JSContext Every AI request leaked its URLSession, the session's delegate, its JSContext (about 150 KB each), and anything the request's closures captured, such as its Cancellation, so memory grew about 1 MB per AI chat message. Each request's URLSession was never invalidated, so it kept its delegate (and through the JS callback, the JSContext) alive forever. The blocks registered into the JSContext also captured the context strongly, a retain cycle that kept it alive on its own. And the request's Cancellation, which callers keep, held the per-call PluginClient. The delegate now invalidates its session when the task completes, which covers success, failure, and cancellation. The registered blocks and the promise handlers capture the context weakly (the call keeps it alive until it finishes), and the Cancellation holds the client weakly. Adds a test seam that reports the objects each call creates, and tests checking that after a completed streaming request, a cancelled request, and a call cancelled before its transfer starts, the delegate, client, JSContext and captured closures are all freed. QA finding F-281. (cherry picked from commit a84fa76ae83e1c9a334fa66fec42bfb5d7e8266f) (cherry picked from commit 68ad33722da7febd1b8bd4823038728bc8b1bdee) 2026-10-14: Keep text editing shortcuts for an alert's text field iTermWarning gives each button without a key equivalent an automatic Cmd- shortcut from its label. Those shortcuts won over the field editor, so in the paste-with-tabs alert Cmd-A opened Advanced and Cmd-C pressed Convert tabs to spaces (which pastes) instead of selecting or copying in the tab-size field. When the accessory holds selectable or editable text, automatic shortcuts now skip A, C, V, X, and Z. Shortcuts the caller assigned are kept. The set of letters already used was never updated because the result of setByAddingObject: was discarded, so two buttons could get the same shortcut, or repeat one the caller assigned. It is now a mutable set, and actions that already have a key equivalent keep it. QA findings F-029 and F-020. (cherry picked from commit 8ff7b90b0e0431aeb564074ea3c87db1a1096a49) (cherry picked from commit 5eebc20a8996a8c4475fe853eb4e72bfeebc5790) 2026-10-14: Keep a manual model's economy requests on the manual model AIMetadata.economyModel(for:in:) fell back to the economy model of the catalog entry sharing the configured model's name. Manual models never carry an economy pointer, so that fallback applied exactly to them: a manual "gpt-5.4-mini" at a gateway with no designated economy model sent every command safety check and screen-watch poll to the gateway as "gpt-6-luna". A gateway that serves only the configured model rejects those, the safety check fails closed, and every AI-proposed command is flagged as potentially dangerous. Only a model's own pointer now selects an economy model, and only catalog models have one. A manual model without a designated economy model gets none, so the safety check and poller use the manual model itself, with its name, URL, headers and credential. Catalog and recommended models are unchanged. QA finding F-125. (cherry picked from commit 06c8d234ffb2733a4324ae835d039851affa8f4d) (cherry picked from commit 5068dba94936db20cdfeb37f901b55ec6ffb60bb) 2026-10-14: Stop localizing the Get Info version string sources/InfoPlist.xcstrings carried CFBundleGetInfoString as a "translated" value of the literal 3.7.dev for de, ja, pt-BR, ru and zh-Hans. Those compiled into each .lproj/InfoPlist.strings and overrode the real version that the build stamps into the base Info.plist, so non-English users saw 3.7.dev in Finder Get Info on release and beta builds. Remove the non-English localizations of CFBundleGetInfoString and mark it shouldTranslate=false (which alone does not stop existing translations from being compiled), so the OS falls back to the base plist value. Add a hygiene test that keeps version keys out of the localized overlays, and correct the Info.plist localization doc. QA finding F-226. (cherry picked from commit 3a393b01607e033b72636c479f5e97200b0c0cdf) (cherry picked from commit b2b53669ea19f61021a0aa814fd0df14497031e3) 2026-10-14: Fix typos and localization gaps in several user-visible strings - Release notes said avilable instead of available. QA finding F-192. - The AI plugin policy error read Plugin not allowed by administator. Fix the source string and the English catalog value; the translations were already spelled correctly. QA finding F-251. - The SSH Integration file transfer description had a stray ") after the user name and a stray " after the file path, which every translation copied. Remove them from the source and all localizations. QA finding F-231. - Settings > Profiles > Window set the background image row label to a hard-coded English Image: or Folder:, overriding the translated XIB title. Localize both strings, reusing the existing translations. QA finding F-229. - The Simplified Chinese title preview showed the English Job, Job+Args, and User. Translate them to match the title component menu items. QA finding F-233. - Fix Artifical in the AI prompt popup menu item (Edit > Engage Artificial Intelligence) in PreferencePanel.xib and its string catalog. QA finding F-112. (cherry picked from commit c5451952214e54096625493309628f5cb49a8332) (cherry picked from commit ced98911d4ab32146e5c6075e83eca2cdd9c79d2) 2026-10-14: Authenticate every open of the standalone password manager window The standalone password manager window, used when no terminal window exists, was created once and reused. Its open gate was made only when it was created, so in Require Authentication on Every Open mode a second open showed the accounts with no prompt, and in the screen-lock modes reopening after a lock showed an empty window with no prompt. Each open now goes through the gate again, as a new window would: a closed window is reopened by replacing its gate and authenticating, and when a prompt is needed the rows, search text and two-factor field from the earlier open are cleared first. A prompt result from an earlier open is ignored. Also, a failed or cancelled prompt from any caller, or a screen lock, revokes the provider's authentication and locks every window, but windows kept showing the account names and user names they had loaded. The provider now posts a notification when its authentication is revoked. Each unlocked window's gate then ends its open, so it stays locked even if another caller authenticates later, and the window clears its rows, ends its sheets and closes, as a screen lock already did. A fetch that finishes after a lock shows no rows. Follow-up to QA finding F-147. (cherry picked from commit d372c17bd73d1c0b42f4b969ec7ac081f4556e1d) (cherry picked from commit acdd8ea649b51b3175d16d477142df0b36aa8ed4) 2026-10-14: Fix sanitizer gaps for Ollama models, adapter paths, and profile triggers tools/sanitize_iterm2_plist.py left the user-chosen Ollama model names (AIOllamaRegularModel, AIOllamaEconomyModel) and the password-manager adapter path (PathToExecutable_, which contains the user name) in sanitized output. Add the Ollama keys to CONTENT_KEYS and the PathToExecutable_ prefix to PREFIX_CONTENT, with matching audit lines. It also redacted the parameter of Set Profile Setting triggers (iTermSetProfileBooleanTrigger), which is only a profile key plus 0/1, leaving the trigger as junk. Keep that parameter when it has exactly the key\x01<0|1> shape; other trigger parameters are still redacted. QA findings F-090, F-208 (resolves F-024), F-209. (cherry picked from commit 795728651f618f7e0b08a91e9c46f0e1ab0d1ee4) (cherry picked from commit cf328152f59ed73a46576b9ae477725bfeb6522e) 2026-10-14: Hide password manager accounts until an every-open prompt succeeds In Require Authentication on Every Open mode, once the password manager had been unlocked earlier in the launch, reopening it showed the account list (account names and user names) behind the authentication prompt. The provider stays authenticated for the whole launch, and the window decided whether to load and show accounts from that flag alone, so it filled the list while its own prompt was still up. Each window now owns a gate that counts the provider's authentication only when the mode lets an earlier unlock be reused, or once this window's own authentication succeeds. Every check in the window that used the provider's flag now asks the gate, so the list stays empty until the prompt succeeds, and a failed or cancelled prompt closes the window as before. Other modes behave as before. QA finding F-147. (cherry picked from commit 3edb24a8d6de7d56ea8742d68041f6e6e75d2262) (cherry picked from commit 3d7851e7f4daf8963929723b7aefea1618949de7) 2026-10-14: Cancel Companion wizard setup when the wizard is closed Closing the Companion setup wizard with Cmd-W while it was downloading did not stop setup. The Close menu item sends closeCurrentSession:, which the wizard did not handle, so the action went to the terminal window behind it (closing a session there) and the wizard kept going: it installed the plugin, raised the administrator password prompt, and on Don't Allow showed Setup Could Not Be Completed. The wizard now handles closeCurrentSession: by closing its window, which cancels the install. The install steps move into CompanionWizardSetupRunner, which checks for cancellation before every step with side effects, treats an error after cancellation as a cancellation rather than a failure, and silently rolls back any consent or API key it wrote. Cancellation no longer depends on the window being visible, so a panel hidden while the app is inactive does not abort setup. The plugin installer stops before installing into Plugins if cancelled during the unzip. QA finding F-036. (cherry picked from commit 26903834ff0f53579871d7a64e818c15acb4b891) (cherry picked from commit 190c9f2fde4f190222f032d864d25685c662e463) 2026-10-14: Keep tmux floats in place on attach by pinning window sizes first On attach, iTerm2 sent refresh-client -C with the profile size before listing windows, so new windows would get that size. With per-window sizes, a control client's size also applies to every window it has not sized itself, so tmux immediately shrank each existing window to the profile size (typically 80x25). tmux clamps floating panes into a shrunken window, and they stay there when the window grows back: a float at x=68 in a 157-column window came back at x=37, and tmux's own layout was rewritten. On tmux 3.8 and later (where floating panes exist), the client size now waits for the initial list-windows. Then iTerm2 sets the client size to the largest window size (which can only grow a window), pins every window at its own current size, and finally sets the profile size, which then only applies to windows created later. Older tmux keeps the previous attach order. QA: please also verify that attaching with tmux older than 3.8 (for example 3.3, 3.4 and 3.7) behaves as before with regard to window and session size: windows should come back at the size tmux had for them (or the profile size, as in 3.7.4), not be shrunk, grown or resized unexpectedly, and tiled pane layouts should be unchanged after attach. QA finding F-049. (cherry picked from commit e93ca9c9f05c38d2a08bc9deadfeb63722138270) (cherry picked from commit c352aadfc76fb96f63458a5569e18c45a098098a) 2026-10-14: Keep @-mention chips on the text baseline A session @-mention chip in the Cockpit composer and the AI chat input is an image attachment. Its image was sized to the laid-out line height of its contents (rounded, with leading) and positioned from the font's exact ascender. TextKit 1 puts a plain line's baseline at a rounded baseline offset, so the chip reached above or below the box plain text gets: a line with a chip was taller, the text after the chip sat lower than on a line without one, and the name inside the chip was drawn off the adjacent text's baseline. The chip image now spans the font's own TextKit line box (without leading, clamped to the ascender and descender) and draws the name at an explicit baseline. When the name's glyphs reach further (an emoji), the box grows only as far as the line the name would get typed as plain text, so a chip never changes the line more than its name would and the name is not clipped inside that line. QA finding F-032. (cherry picked from commit 87c573476f3b269146363861a2626c3e4ac649db) (cherry picked from commit 3240bb3b84db8a2f507dd61d2d68f4b6e62ebe46) 2026-10-14: Recover from an SSH Integration connection that dies abruptly When an it2ssh connection ended without the framer's clean exit (the remote sshd killed, or the local ssh client killed), the session wedged: the local shell was back in the foreground, but nothing it printed was drawn and keystrokes were queued forever. On a clean exit conductor.sh prints an OSC 135 unhook, which drops the DCS 2000p conductor hook, and then the local it2ssh prints OSC 1337;EndSSH=, which tears down the conductor. On an abrupt end no unhook arrives, so the hook stayed in place. It only recognized ST-terminated OSCs, so the BEL-terminated EndSSH and all later output, including the local prompt, were swallowed. The conductor parser now remembers the it2ssh unique ID from its init line and treats that it2ssh's EndSSH as an implicit unhook. It leaves the EndSSH unconsumed so the regular parser executes it, just as after a clean exit. An EndSSH relayed inside %output is honored only for this connection's ID, so a nested it2ssh's EndSSH is not mistaken for it. The OSC parser also now backs up to the ESC when an OSC is interrupted by another one, instead of losing the ESC, so an EndSSH that follows a framer OSC cut off by the dying connection is still recognized. QA findings F-120 and F-269. (cherry picked from commit e01c4b065539ecd72e968099a816ae6d071df03a) (cherry picked from commit d9331d06e6b1e9c94cf885b3376cdeaed3b64b52) 2026-10-14: Restore an archived floating pane as a float on Undo Close With Archive On Closure on, Undo Close of a floating pane whose undo window had run out restored nothing. The session was gone, so recreateTab: had no live session to put back as a float. It stripped the float records from the tab arrangement and rebuilt the tiled layout, which succeeds without the float, so the archive was silently dropped. Now recreateTab: finds archives whose session has a float record in the tab arrangement and restores each one as a float from that record (its frame, container size and grid), using a per-record helper extracted from restoreFloatingPanesFromArrangement:. An archive restored this way does not also get a tab of its own. If the float can't be restored, or its tab is gone, the archive gets its own tab as a tiled pane's does. QA finding F-004. (cherry picked from commit 62ecc13ba0f3d274e60cae4aeec91e715e263450) (cherry picked from commit 1e2e3e5c6dc7c66225beb6b19461e1e265eedc1d) 2026-10-14: Type a space instead of . for double-space period substitution With macOSs Add period with double-space on, pressing space after a space makes the text input system call insertText:. with a replacement range covering the space before the cursor. The keyboard handler backspaced over that space and sent . , and in vim (where it doesn't backspace) the period repeated the last command. The fix for issue 13123 sets NSAutomaticPeriodSubstitutionEnabled to NO in iTerm2's user defaults, but NSSpellChecker reads the standard user defaults. When iTerm2 runs with a custom defaults suite (-suite), the override never reaches AppKit and the global setting wins, which is how QA saw it after using the Composer. The keyboard handler now recognizes the substitution (a . insertion replacing one character during a space keypress, with no marked text) and inserts the space the user typed. Dictation, Voice Control, and input methods committing marked text are unaffected. QA finding F-217. (cherry picked from commit e624cfe8e3914da1b8146e35ffcec47389088970) (cherry picked from commit dff3ea495854391b518080e60f4f3f75c15adda0) 2026-10-14: Don't show the locale prompt as a sheet on an unseen hotkey window When no valid UNIX locale matches the user's language and region, the first session of a dedicated hotkey window asks the user to pick one. That session is launched while the hotkey window is ordered in but still has an alpha of 0 (it only rolls in after its session exists). The prompt attached itself as a sheet whenever the window was visible, so it became a sheet on a fully transparent window: the user saw nothing, key focus was trapped in the hidden sheet, and the hotkey window could not appear until the sheet was answered blind. Only use a sheet when the window is visible, has nonzero alpha, and is not miniaturized. Otherwise activate iTerm2 and show an app-modal alert. The same rule applies to the wrong-encoding warning shown for a custom non-UTF-8 locale. The session still launches with the locale the user picks, as before. QA finding F-306. (cherry picked from commit 4514cefcbda025da1ce107a17d3759ba511f2b74) (cherry picked from commit 4ab632807c49f3117d39bc02f1e898279a91812e) 2026-10-14: Coalesce title changes so a title flood cannot swamp the main thread Each OSC 0, 1 and 2 added its own side effect. A window title in a session with no remote host also asked the main thread for the working directory, which sent one XPC request to the pidinfo service per title, armed a timeout timer on the main queue, and on completion went back to the mutation queue to log the directory, which queued another side effect that saved the recent directories database and updated the path variable. A program setting the title as fast as it could write queued hundreds of thousands of these. The main thread fell minutes behind and memory grew to gigabytes, held by the outstanding requests, their timers and closures, and the side effects they queued, until the backlog drained. Title changes with no other side effect between them now share one batch and one side effect, using the token executor's side-effect serial as the OSC 7501 batches do, so they keep their order relative to bells, reports and other side effects. The batch keeps only the latest window title, icon title and subtitle, whether to poll the local directory, and the first and last line at which to log the working directory, which is fetched once per batch. A change that needs the directory does not join a deferred batch, so it is not delayed as it was not before. QA finding F-327. (cherry picked from commit e9cb2fec5aedf79c001a0f6632d223f26d373072) (cherry picked from commit 616635a8fad25940e1318cb9d018b6c5dee4f754) 2026-10-14: Show the basic-auth sheet instead of prompting in every-open mode In Require Authentication on Every Open mode, once the browser password manager had been unlocked, every basic-auth challenge for a remembered site raised the system open the password manager prompt. The basic-auth store only auto-fills when the password manager can be read without a prompt, but its check treated any earlier unlock as reusable. Every-open mode never reuses one, so the silent lookup prompted instead. The check now counts an earlier unlock only when the mode allows it to be reused. In every-open mode a challenge shows the plain basic-auth sheet, and the user can still choose the password manager from it, which authenticates as usual. QA finding F-099. (cherry picked from commit 793d09e4f007954d06004a250aded514fc8e731c) (cherry picked from commit 4ca3cf60aca8db6113b72183912e656ff1064deb) 2026-10-14: Revert the session.path ssh-exit heuristics built on the F-103 fix Reverts f72f61f2b, 4d2232a7a and 4b107878f. They tried to let a polled local directory replace a remote session.path after ssh exits, for a local shell that has no shell integration while the remote one does. Each rule exposed another edge case (subshells, tmux panes, sudo -s, screen, a race recording the foreground process group), and a review found the latest one could leave the path stuck on the remote directory. This keeps 01b50a4b1 (QA finding F-103), which restores the original Objective-C rule: a polled local directory never overwrites the path while a remote directory is current. After leaving ssh into a local shell without shell integration, session.path stays on the remote directory until the next directory report, as in 3.6. QA findings F-134, F-138, F-141, F-142 and F-144 are known limitations. (cherry picked from commit 10ce518ddcfa4a2e045a574e61ccfd67625d0df3) (cherry picked from commit e225d36f637b4f5e5f0ce7f9fe8b5949d7c9232e) 2026-10-14: Name the reattached tmux pane's workgroup in a re-bound AI chat watch When an orchestration chat's watch on a tmux -CC pane was held across a relaunch and re-bound to the pane's recreated session, only its session reference was updated. Its workgroup ID still named the old session (session:), while list_workgroups lists the pane under its new stableID, so list_watches and the watch's status_update named a workgroup that no longer exists and the model could not match them. The re-bind now also takes the workgroup ID, workgroup name, role ID, and role name from the new session, using the same context list_workgroups uses, before persisting the watcher and re-arming it. QA finding F-145. (cherry picked from commit 222fbdced7227a14bd08887ebaf1956a49bf9de4) (cherry picked from commit e63f326eac0d6d8bab34d1dec1b7c02aeaa8aa96) 2026-10-14: Cancel only the stopped AI request, not another chat's PluginClient was a singleton that remembered only the most recently started HTTP task, and every AI request's Cancellation cancelled through it. With two chats replying at once, pressing Stop in the chat whose request started first cancelled the other chat's request (it ended with "HTTP request failed with cancelled") while the stopped chat's transfer kept running to completion, still using tokens. Cancelling a request that had already finished could also kill another chat's request. Each plugin call now gets its own PluginClient, and Plugin.load ties the request's Cancellation to that client, so cancel stops exactly that request's transfer. The client also remembers that it was cancelled so a cancel that lands before the HTTP task is created keeps it from starting. Adds test seams (URLProtocol classes, a plugin built from test code, and a plugin override on iTermAIClient) and tests covering two concurrent requests, cancelling a finished request, and cancelling before the transfer starts. QA finding F-268. (cherry picked from commit 58bd67892e9f09b265c63a9d25437e1070f5848f) (cherry picked from commit 8e30bfd5d1a81d19024cc266d15efcde3da3ff7f) 2026-10-14: Keep the Claude Code hook when the config dir is an alias of ~/.claude Installing the Claude Code integration merges the cc-status hooks into settings.json in the resolved config directory, then strips them from the previously used directory if it changed. The "changed" check compared standardized path strings, so a CLAUDE_CONFIG_DIR that names the same directory as ~/.claude through a symlink (~/.claude -> ~/dotfiles/claude) or a case variant on a case-insensitive volume (~/.Claude) looked different, and the strip removed the hooks Install had just written. Decide sameness by file identity (device and inode via stat) for both the directories and their settings.json files, falling back to comparing symlink-resolved paths, case-insensitively only on a case-insensitive volume, when either can't be stat-ed. The strip step is now stripStaleCCStatusHooks(previousDirectory:newConfigDirectory:) so it can be unit tested. QA finding F-274 (also F-013, F-064). (cherry picked from commit 0a2c82e44d9725c9d4b4d0273b06620c70454683) (cherry picked from commit 70789d73a4f05b3ae195f3c2704dd489f8017d57) 2026-10-14: Keep AI chat watches on tmux panes across a quit that restores windows On an ordinary quit with window restoration on, an orchestration chat's watch on a tmux -CC pane was still dropped when the pane closed, because the session-termination handler treats a tmux client session as never restored. Since watches registered from an orchestration chat now record the pane's tmux identity, and the next launch holds such a watch until the pane reattaches and re-binds it, there is no reason to drop it at quit. When a tmux client session terminates for a quit after which windows will be restored, the dispatcher now keeps that session's orchestration-chat watches that carry a tmux identity. If the pane does not reattach at the next launch, the existing five-minute deadline drops it. Real pane closes, Option-Quit, quits without restoration, watches with no tmux identity, and session-bound chats keep the old behavior. Follow-up to QA finding F-126. (cherry picked from commit 78e184447be23740253cb111abfa9bb042d71d61) (cherry picked from commit c3d383a62fa570accfb6dc6d2d4373db0ca2fd35) 2026-10-14: Tell the user when Stop or Disable Orchestration cancels AI chat timers Stop and Disable Orchestration cancel a chat's pending register_timer timers and watches without telling the model, so nothing told the user either, and they could keep waiting for a timer that would never fire. Both now publish one chat notice counting what was cancelled, such as Stop cancelled 1 pending timer. or Disabling orchestration cancelled 2 pending timers and 1 watch. The notice is client-local, so it starts no agent turn and sends nothing to the model. Nothing is published when nothing was pending. Disable Orchestration's drop, clear, and notices move into OrchestratorClient.disableOrchestration so the counts are read from the persisted set just before it is cleared, with or without a live dispatcher. The strings are pluralized and translated into German, Japanese, Brazilian Portuguese, Russian, and Simplified Chinese. Follow-up to QA finding F-110. (cherry picked from commit a16ee361ca209b19a5266b18d7c991667dd3d50b) (cherry picked from commit 471808c3086e24f2ca559093f45189b822d37af7) 2026-10-14: Keep AI chat watches on tmux panes across a relaunch After a kill -9 and relaunch, an orchestration chat's watch on a tmux -CC pane was dropped at launch with "could not be restored", which also started a paid agent turn. Watchers persist only the session's stableID, but a tmux client session is not restored by window restoration: the restored gateway reattaches tmux after the restoration gate has opened, and each pane comes back as a new session with a new stableID. Watchers now also record the pane's tmux identity (the tmux session's @iterm2_id and the pane number) when registered from an orchestration chat. At reconcile, a tmux watch whose session is missing is held instead of dropped, and is re-bound to the pane's new session when tmux opens its window. It is dropped if that tmux session detaches (the detach notification now carries its @iterm2_id) or if the pane has not reattached five minutes after reconcile. A held watch gets no screen backstop until it is re-bound, and a held copy that duplicates a watch registered again on the reattached pane is discarded. Session-bound chats keep the old behavior, since their link still names the old pane session. QA finding F-126. (cherry picked from commit 098675fcf2fb8bd1b6d672df4b5d0a5c3ec29e9f) (cherry picked from commit 2fe05b81ae21dfa68eee5a63a267e8d73f1db16e) 2026-10-14: Keep pinned tabs out of unpinned groups on Move Tab Left/Right Move Tab Right on a pinned tab whose right neighbor was an unpinned tab group made the tab join that group in place. Joining copies the group's pinned state onto the tab, so the pin was silently lost. The symmetric case, Move Tab Left on an unpinned tab next to a pinned group, silently pinned the tab. iTermTabGroupOrdering.singleTabMove now takes the tabs' pinned states and makes the join a no-op when the neighboring group is across the pinned boundary, matching how a plain reorder across the boundary is already clamped. Joins on the same side of the boundary are unchanged. QA finding F-256. (cherry picked from commit 9671c608685c2cbb0900e95e1618ab6869f62a8a) (cherry picked from commit 8d9f0a05ed02b1ec2a3ffbace78455e6816e6bf3) 2026-10-14: Keep AI chat timers when a chat is unlinked from its session Unlinking a session-bound chat from its terminal session, which also happens when the linked session closes, cancelled every watcher on the chat, including register_timer timers. A timer targets no session, so the model was told it would fire, but nothing fired and list_watches came back empty. The unlink path now drops only the session watches it orphans and leaves timers armed and persisted, both with a live dispatcher and in the client's no-dispatcher path. Stop and Disable Orchestration still cancel timers: Stop exists so no later event can wake the agent, and disabling orchestration clears all watchers. QA finding F-110. (cherry picked from commit 9941b60b3dce8575f2967000f7a2b54e6af13893) (cherry picked from commit f8556102e43cdd212477a4b8bded3f6bdc6b66a0) 2026-10-14: Expand {{FILE}} in AI suggestions with the whole filename File completion items carry only the text to insert after the word being typed, so typing er with error.log present yields ror.log. AI command suggestions substituted that suffix for {{FILE}}, so rm {{FILE}} was offered as rm ror.log, which runs rm on the wrong name. Rebuild the name from the typed word plus the suffix and shell-escape it, since AI suggestions are inserted without the escaping applied to plain file completions. An append whose text begins with {{FILE}} is inserted right after the partial word, so it still gets just the suffix there. QA finding F-114. (cherry picked from commit e44fceefb41bd3814d585111a6f461fdc1b73811) (cherry picked from commit 1d7f17088d010446b26534bd8a5ce7362ecd53be) 2026-10-14: Keep one rate-limit timer per program status notifier ProgramStatusNotifier treated a non-empty held set as meaning its post timer was scheduled. Handling a batch of OSC 7501 reports can drop every held notification (the record went back to working) while the timer is still due, so the next notification inside the interval scheduled a second timer for the same deadline. Under a flood, batches ran between those timers and each timer posted, giving two or three notifications within moments of each other instead of one per three seconds. The notifier now tracks whether its timer is outstanding explicitly, clears that when the timer fires, and holds new notifications while it is outstanding. QA finding F-326. (cherry picked from commit a434348d5bffd30cd137df4d737b8643aaa7196f) (cherry picked from commit c8bbd5a75d0769df0475eea8a608bfdfc51ea7d4) 2026-10-14: Bound AI Usage command memory and kill its whole process group A custom AI Usage command that floods stdout, such as /usr/bin/yes, grew iTerm2's memory by gigabytes before the 20 s timeout even though captured output was capped at 1 MiB. iTermCommandRunner's read loop called -availableData outside its per-iteration autorelease pool, so every chunk read stayed alive until reading finished. The read now happens inside the pool, which bounds memory for every caller of the runner. The timeout also killed only the direct child. A wrapper whose child kept stdout open (for example a script running sleep 60) left the runner reading until that child exited, and orphaned children piled up. iTermCommandRunner gains an opt-in terminatesProcessGroup, which makes -terminate SIGKILL the command's process group (NSTask makes the command its group leader), and iTermBufferedCommandRunner gains an opt-in terminateWhenOutputExceedsMaximum, which terminates the command once the output cap is exceeded. The AI Usage item enables both; other callers keep their existing behavior. QA findings F-116 and F-117. (cherry picked from commit 4cd0c197329ee9e1d8bec03f643af5156cc961f2) (cherry picked from commit e043a7a738cda93c2bcd2f8f3c0b35f3611c02d7) 2026-10-14: Hold a remote session.path only while ssh's process group is in the foreground Three fixes to session.path (F-103, F-134, F-138) each added a special case: a polled cwd baseline taken after a remote push, and a shell/other classifier of the foreground job. Both broke in new places. ssh started from a local subshell kept the remote path after exit, because the subshell never classified as the shell (F-141). In a tmux integration pane the baseline was taken after the first local cd, which was then swallowed (F-142). Replace them with one rule. When a remote shell first reports its directory, record the local terminal's foreground process group (the group ssh runs in, or the root when the profile's command is ssh). The remote directory stays current only while that group is still in the foreground. A valid poll that finds anything else there, or can't read it, makes the polled local directory current. Later pushes in the same remote stretch keep the recorded group, so an OSC 7 processed after ssh exits still ends it. tmux integration panes have no local terminal to read, so polls win there as in 3.7.4. The group is saved in the arrangement so a session whose ssh survives a relaunch keeps its remote path; a new shell goes local on the first poll, and older arrangements behave as in 3.7.4. Known limit: wrappers that run their own pty (sudo with use_pty, script, screen, a local plain tmux) stay in the foreground, so a remote path set inside them lasts until they exit, as it did before this change. QA findings F-141 and F-142. (cherry picked from commit f72f61f2b72ceda3349eb89a68e3b52756fac24a) (cherry picked from commit 0c600af155b138da17880c3f1ec1f1426d48101f) 2026-10-14: Don't overwrite an unparseable Claude Code settings.json on install Installing or reinstalling the Claude Code integration read settings.json with try? and fell back to an empty object when the file couldn't be read or parsed, then wrote the result. A settings.json with a single syntax error was replaced by a file holding only the cc-status hooks, losing the user's permissions, env, model, and every other setting without warning. Move the read/merge/write into mergeCCStatusHooks, which refuses to write an existing file that can't be read or doesn't parse as a JSON object and returns a result the wizard turns into a localized alert naming the file and asking the user to fix or remove it and try again. A missing or blank file still starts from an empty object. The uninstall path already left such files untouched; tests now pin that too. QA finding F-309. (cherry picked from commit acc5a7feae933c6d458529138f4db37f9fc7b8a4) (cherry picked from commit b602ce99379af8cf8f6a31c5ac1030b73e1abfa5) 2026-10-14: Give the AI key hint room for its longest translation The hint under API key in the manual AI model editor had a fixed two-line height, so the German hint for a gateway endpoint (four lines at the label's width) was cut off after Whlen Sie, and several other hints needed three lines in German, Japanese, Portuguese and Russian. The same hints in Settings > General > AI were truncated to one line, in English too. The editor now measures every hint it can show in the running language at the label's width, sizes the label for the longest (keeping two lines as the minimum, so English is unchanged), and moves the controls below it and the window's height by the difference. In Settings the hint wraps to two lines, which every shipped language fits in the width visible in the tab, and the Timeout row and safety checkbox move down to make room. Should a future translation need more, the second line ends in an ellipsis. QA finding F-140. (cherry picked from commit ebbc6da6d71664616379fb45cfe46f3a378caef6) (cherry picked from commit 476cc855cb289b382b2a46897bb5f32f97f0c8d4) 2026-10-14: Update session.path when the local shell returns from ssh without a cd After ssh'ing somewhere whose shell reports its directory with OSC 7 and then exiting back to a local shell without shell integration, session.path kept the remote directory until the user changed directory locally. The fix for F-134 only accepted a polled local cwd that differed from the first one seen after the remote push, and the local shell comes back in the directory it ssh'ed from. The directory tracker now asks its delegate what is in the foreground of the local terminal. PTYSession reads the terminal's foreground process group from the kernel and reports whether the polled process (the shell, or login's child) leads it. Once another job such as ssh has been seen in the foreground while the directory is remote, a valid poll that finds the shell in the foreground again becomes the current directory even if it did not change. Requiring another job first keeps the remote directory for profiles whose command is ssh itself, and tmux integration panes report unknown and keep the earlier behavior. QA finding F-138. (cherry picked from commit 4d2232a7ac5f180239e62e91507d76a215620990) (cherry picked from commit 425e2720c8d43d425643e12e4cdf5a30278d083c) 2026-10-14: Coalesce OSC 7501 reports so a flood cannot swamp the main thread Each OSC 7501 report added its own deferred side effect, and each one ran the full program-status update on the main thread: comparing records, posting the records-did-change notification, building display records, calling the notifier and re-applying the tab status. A program flipping a task between two states as fast as it could write queued about a million side effects in ten seconds, so the session fell minutes behind drawing, memory grew to gigabytes, and rate-limited notifications kept arriving until the main thread caught up. Reports with no other side effect between them now share one batch and one side effect. The token executor exposes a side-effect serial so a report only joins a batch whose side effect is still last in the queue, which keeps reports in order relative to resets, OSC 21337 and other side effects. The batch keeps only each record's latest report, at the position of its last write, with clears as barriers, so a flood that cycles among a few records holds one report per record. The program status controller handles a batch at once: the records change once, the tab status is set once, and each record whose state changed over the batch, or that was written after a clear, is announced once in its final state. States a record only passed through within a batch were never on screen and are not announced. QA finding F-307. (cherry picked from commit db21cddb4f9ca911198385c1fe0a76368de95d6a) (cherry picked from commit 4177935d8d601910e54f6b5247d740f006e4aef7) 2026-10-14: Translate the AI key strings and fix two zh-Hans Companion explanations Commit 29e978661 added three user-visible strings (the advice shown when a third-party AI gateway rejects a request because no API key was sent, the Settings hint for an endpoint not run by the model's provider, and the "Automatic (none for this endpoint)" API key popup title) but never added them to xcstrings/Localizable.xcstrings, so they appeared in English in every language. Add the three entries with German, Japanese, Brazilian Portuguese, Russian, and Simplified Chinese translations that follow the terminology of the neighboring AI settings strings. Also fix two Simplified Chinese Companion strings: Companion.InstallExplanation.Full held unrelated text instead of a translation, and Companion.InstallExplanation.CompanionOnly said data is sent to this Mac where the English says off this Mac. Commits 379a977a7 and 4b107878f were also checked; they add no new user-visible strings. Follow-up to QA findings F-092 and F-272. (cherry picked from commit 8859f4f7691da0a20d07634010ceeaaed23af380) (cherry picked from commit 2ddaa8e1ac5aa1493f0924e354b075abdddacd2a) 2026-10-14: Translate the Companion wizard strings added for AI model setup Commit 1dcf0564f added six user-visible strings to the Companion setup wizard (the choose-mode details for an already configured or manually chosen AI model, the full-setup explanations that replace the API key field, the cannot-authenticate setup failure, and the status shown when AI settings change while the screen is open) but never added them to xcstrings/Localizable.xcstrings, so they appeared in English in every language. Add the six entries with German, Japanese, Brazilian Portuguese, Russian, and Simplified Chinese translations that follow the terminology of the neighboring Companion strings. Commits 3e141285d and 3981d7ecb were also checked; they add no new user-visible strings. QA finding F-272. (cherry picked from commit 514080945329a903e8a9749b1cc4f50d1b5917fb) (cherry picked from commit 8eba4c21343e822c4e7c3577777a929d8de012bb) 2026-10-14: Send the OpenAI key automatically only to Azure's AI service hosts Under the Automatic key choice, any host ending in .azure.com counted as OpenAI's own host, so it got the user's OpenAI key. But anyone with an Azure VM chooses the label of its public DNS name (