Skip to content

Conversation

yellcorp
Copy link

@yellcorp yellcorp commented Apr 7, 2025

Update ITerm2ImageDisplayer to support iTerm's 'multipart' file escape sequences. Adds a new setting iterm2_multipart_file_chunk_size to control the size of these parts, which may be necessary in some situations.

ISSUE TYPE

  • Bug fix
  • Improvement/feature implementation

RUNTIME ENVIRONMENT

  • Operating system and version: macOS 14.6.1 (Sonoma)
  • Terminal emulator and version: iTerm 3.5.12
  • Python version: Python 3.9.6
  • Ranger version/commit: b31db0f (1.9.4)
  • Locale: en_US.UTF-8

CHECKLIST

  • The CONTRIBUTING document has been read [REQUIRED]
  • All changes follow the code style [REQUIRED]
  • All new and existing tests pass [REQUIRED]
  • Changes require config files to be updated
    • Config files have been updated
  • Changes require documentation to be updated
    • Documentation has been updated
  • Changes require tests to be updated
    • Tests have been updated

DESCRIPTION

iTerm2 quietly introduced a change to how it handles escape sequences, which has consequences for image previewing. Since v3.5.0, it has imposed a hard limit of 1MiB on all escape sequences.

To retain support for large files, it introduced a new 'multipart' protocol variant intended to transfer a file as a series of escape sequences rather than a single big one. This change updates ITerm2ImageDisplayer to use this newer variant.

Also added is a new setting, iterm2_multipart_file_chunk_size, which controls the chunk size, or falls back to the old single-part variant when set to 0.

MOTIVATION AND CONTEXT

I wasn't able to find any existing issues relating to this, but I've certainly been running afoul of it, and the fix is fairly straightforward, so instead of an issue here's a PR.

Since iTerm v3.5.0 (May 2024), images with a base64 representation larger than 1MiB fail to display properly when the preview_images_method is iterm. The way this manifests is by the terminal window being filled with base64 text instead of the highlighted image. This change restores the ability to preview large images.

I was reluctant to expose such a fine-grained detail like iterm2_multipart_file_chunk_size, but various sources suggest there are a few situations where a user may want to tweak this, and I don't believe these situations can be reliably detected automatically. This also feels acceptable because of the presence of other iTerm2-specific settings (namely iterm2_font_{width,height}.

"Old" versions of tmux are specifically called out in iTerm's documentation1, and iTerm's imgcat utility2 chooses a conservative chunk size to maximize compatibility. "Old" is not quantified, but discussion suggests it applies to a tmux limitation that was lifted in 20143. Other discussion suggests slow connections may be another situation where a user might want to lower this4.

Also, users of iTerm 3.4.x and older will want to disable multipart encoding entirely, as such versions won't understand it. A iterm2_multipart_file_chunk_size of 0 will revert to the older "File" variant - the behavior ranger had before this PR.

I've settled on a default of 1048320, which is the 1MiB hard limit less 256 bytes for escape sequence overhead. Another approach could be to copy imgcat's default of 200 bytes for greatest compatibility and include some guidance to increase it, but:

  • this limitation of old tmux would have been encountered in older versions of Ranger, whereas this new limit is imposed by iTerm.
  • 200 is a very small chunk size and the ~27 bytes escaping per chunk results in noticeably increased latency when previewing an image.

TESTING

No automated testing - I couldn't find any existing tests relating to image display. I have manually tested that this works in iTerm 3.5.12 on macOS 14.6.1 with its stock version of Python at 3.9.6, both with and without tmux 3.4, running both locally and over ssh.

Functional changes are entirely contained within the ITerm2ImageDisplayer class in ranger/ext/img_display.py, with the exception of ranger/container/settings.py - edited to add the iterm2_multipart_file_chunk_size setting. Changes to docs and .conf files have been kept near other iTerm-related areas and try to follow their style.

Footnotes

  1. https://iterm2.com/documentation-images.html

  2. https://iterm2.com/utilities/imgcat

  3. https://github.com/tmux/tmux/issues/487

  4. https://github.com/tmux/tmux/issues/1388

iTerm2 v3.5.0 introduced a change which imposes a hard limit of 1 MiB on
the length of escape sequences it will parse. Escape sequences longer
than this will force iTerm2 to bail on parsing, which results in the
file content spilling into the terminal as visible base64 characters.

To retain support for large files, it introduced a new 'multipart'
protocol variant intended to transfer a file as a series of escape
sequences rather than a single big one. This change updates
`ITerm2ImageDisplayer` to use this newer variant.

Also added is a new setting, `iterm2_multipart_file_chunk_size`, which
determines the chunk size. In many (hopefully most) cases, the default
added to rc.conf should suffice.  I even tried to avoid adding it, but
reference implementations and documentation suggest there are a handful
of situations where a user may want to tweak this value to make image
display work, and I don't believe these situations can be reliably
detected automatically. "Old" versions of tmux get a specific mention,
and anyone using iTerm versions older than 3.5.0 will want to force the
old protocol, which can be done with a value of 0.
@yellcorp yellcorp force-pushed the yellcorp/iterm-image-multipart branch from 85b6f0a to 6c86b17 Compare April 7, 2025 02:16
@yellcorp yellcorp marked this pull request as ready for review April 7, 2025 02:16
Copy link
Member

@toonn toonn left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code LGTM, some sort of queue where we can slice chunk_size characters off the top every time would make things a bit clearer but I don't know of one in the Python stdlib.

I'm tagging this in the hope someone else steps forward to confirm things work but otherwise I'll merge it in a week or two. Ping me if that doesn't happen.

@yellcorp
Copy link
Author

yellcorp commented Apr 8, 2025

To be honest, I was grabbing the _encode_image_content just to reuse what was already there, but I realize that this is the only place it's used, so we could just do a humble open in binary read mode, and get the plaintext chunks by repeatedly calling .read(n). That's kind of queue-ish!

n would have to be the number of plaintext bytes, calculated from chunk_size (which measures encoded bytes), but that's probably still clearer than using a stepped range and slicing. Definitely more memory efficient too, if that's a concern (this is a PR addressing large files after all, at least for certain values of "large").

Now I'm also thinking that perhaps the iterm2_multipart_file_chunk_size setting should include the escape sequence bytes - the function can subtract the length of the escape sequence framing after it figures out whether it's in tmux or not. Then we don't have to worry about vague notions of "headroom", we can just communicate the setting like "Is old tmux limiting you to 256 bytes? Then set it to 256"

Anyway, I can get this all down in another commit in a moment and then we can see how badly I'm overthinking this 😅

yellcorp added 2 commits April 8, 2025 22:51
This setting now determines the maximum length of the escape sequences
sent - that is, the value *includes* the opening and closing strings for
each `FilePart` sequence. `ITerm2ImageDisplayer` then figures how many
bytes are left over per chunk for file data.

This maps more cleanly to the various constraints that make this option
necessary in the first place - i.e. iTerm2 3.5+ maxes out at 1048576
bytes, so set exactly that value here. Where old tmux source shows a
buffer size of 256, set 256. No need for notions of 'overhead'
This makes for a more idiomatic read-out of the chunk data. Like any
other file, now we just read(n) until we get an empty string

Adds a six-style StringIO helper in ranger/ext/stringio.py
@yellcorp
Copy link
Author

yellcorp commented Apr 9, 2025

Okay, I thought streaming the file by incremental reads would be straightforward, but there are enough nuanced edge cases that I think memory optimization should be a separate goal/enhancement.

The difficulty stems from iTerm's requirement that the file size be sent in the header. On the face of it, that seems trivial - just os.path.getsize or do a fileobj.seek(0, 2). But that risks a time-of-use bug if the file size changes during send, and I'm not sure how iTerm behaves if there's a mismatch between the number of bytes announced in the header vs the number of bytes sent. Both single-part and multi-part variants should have enough information to detect such a mismatch, i.e. the closing \a or FileEnd coming too early or too late, but I'd want to research it rather than make assumptions.

Also, we'd probably want a context manager to make sure that final \a or FileEnd gets sent while still propagating/reporting IO exceptions. The status quo is far simpler: read it in one shot - raise any exceptions before the file protocol is started, then send what we got with metadata that was correct at the time of reading. So I'll stick with that.

During all of this, I realized we can use StringIO for a more idiomatic way of dicing buffered data into chunks, so I've switched to that. It has necessitated a six-style shim though. I've put it in ranger/ext/stringio.py

Also, I went ahead and made the change to how iterm2_multipart_file_chunk_size is interpreted.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants