DownloadTerminal Sixel Image Rendering for Markdown Messages
> Draft Notice: This proposal is a draft generated by AI and may not have been reviewed for accuracy. It is intended as a starting point for discussion and implementation planning.
Table of Contents
-
Overview
-
Goals
-
Non-Goals
-
Background
-
User Experience
-
Architecture
- Image Detection in TerminalMarkupRenderer
- SixelImageRenderer Class
- Image Acquisition
- Conversion via img2sixel
- Writing Sixel Data to the Terminal
-
Integration Points
- EchomailHandler / NetmailHandler
- Status Bar Hint
- Image Viewer Flow
-
Configuration
-
Caching
-
Security Considerations
-
Netmail Parity
-
Implementation Phases
-
Open Questions
Overview
Markdown messages in echomail and netmail can contain inline image syntax ( ). The terminal server currently renders these as plain text (alt text + URL). This proposal adds the ability for users to view those images as Sixel graphics directly in the terminal, without consuming bandwidth automatically. The user must explicitly request each image; nothing is downloaded on message open.
Goals
-
Allow any terminal user to view inline markdown images on demand, regardless of whether Sixel support was detected at login.
-
Never download or render any image without an explicit user action (bandwidth opt-in).
-
Support both locally-hosted images (`/echomail-images/?`) and external URLs.
-
Keep the implementation confined to the telnet/SSH layer; no changes to the web API or database schema are required.
Non-Goals
-
Automatic inline rendering of images alongside message text (too much bandwidth, too disruptive to text layout).
-
Animated GIF playback.
-
Images in non-markdown messages (plain text, StyleCodes).
-
Any changes to how images are handled in the web browser interface.
Background
Sixel support detection
telnet/src/BbsSession.php already probes for Sixel support via a Primary Device Attributes (DA) query during session startup and stores the result in $this->sixelSupported (line 82). This boolean is the correct gate for all Sixel output ? no additional detection work is needed.
img2sixel
img2sixel is a command-line tool from the libsixel project. It accepts an image file path or - on stdin and writes Sixel data to stdout. It handles JPEG, PNG, GIF, and WebP input and supports flags for width/height scaling and palette depth. It must be installed on the server (apt install libsixel-bin on Debian/Ubuntu).
Existing sixel handling
The web frontend already has a client-side Sixel decoder in public_html/js/sixel.js. That code is irrelevant to the terminal path ? the terminal sends Sixel data as raw bytes, which the terminal emulator renders natively without any JavaScript.
Locally-hosted images
The existing proposal docs/proposals/MarkdownImages_Proposal.md describes image upload and hosting at /echomail-images/{username}/{slug}. Images are stored on disk under data/echomail-images/ and served through a PHP route. For terminal use we can read these files directly from disk, bypassing the HTTP layer entirely.
User Experience
-
User opens a markdown message that contains one or more image references (`
`).
-
The message body renders as normal text. Images are replaced by numbered placeholders:
Image 1: sunset.jpg
If there are multiple images, each gets its own number:
[Image 1: cat.png]
[Image 2: https://example.com/dog.jpg]
-
The status bar gains a hint ? e.g. `I View image` ? when at least one image is present.
-
The user presses `I` (or `1`, `2`, ? if multiple images) to view a specific image.
-
The screen clears, a header line shows the image number and alt text, and the Sixel data is written to the terminal. The image appears.
-
Below the image, a prompt reads:
`Press any key to return to the message.`
-
Any keypress returns to the message viewer at the same scroll position.
The image viewer option is always available when a message contains image references, regardless of whether Sixel support was detected at login. On terminals that do not actually render Sixel the output will appear as garbage characters; this is a known limitation and may be addressed later (e.g., by re-introducing the detection gate or adding a user preference to suppress image keys).
Architecture
Image Detection in TerminalMarkupRenderer
TerminalMarkupRenderer::renderMarkdown() currently calls expandMarkdownLinks() for paragraphs, which collapses  into alt + URL text. A new static method, extractImageRefs(), scans the raw cleaned text and returns all image references in order:
/
* Extract markdown image references from a message body.
*
* @param string $format Markup format (only 'markdown' produces results)
* @param string $text Raw message body (may contain kludge lines)
* @return array<int, array{index:int, alt:string, url:string}>
* Each entry: 1-based index, alt text, and URL.
*/
public static function extractImageRefs(string $format, string $text): array
This uses preg_match_all('/!\[([^\]]*)\]\(([^\)]+)\)/', $clean, $m) after stripping kludge lines.
The markdown renderer's renderMarkdown() is updated to replace  with a numbered placeholder line:
[Image N: alt text]
The placeholder is rendered in dim/italic style so it is visually distinct from body text.
SixelImageRenderer Class
A new class telnet/src/SixelImageRenderer.php handles all image acquisition and conversion:
namespace BinktermPHP\TelnetServer;
class SixelImageRenderer
Responsibilities:
-
Resolve a URL to image bytes (local file read or HTTP fetch).
-
Pipe image bytes through `img2sixel` to produce a Sixel byte string.
-
Write the Sixel data to a socket connection.
-
Enforce size and timeout limits.
Key methods:
/
* Fetch image bytes from a URL (local or remote).
* Returns null on failure; sets $error on failure.
*/
public function fetchImage(string $url, string &$error): ?string
/
* Convert raw image bytes to Sixel data via img2sixel.
* Returns null on failure; sets $error on failure.
*/
public function convertToSixel(string $imageBytes, int $maxWidth, string &$error): ?string
/
* Write Sixel data followed by a cursor-positioning reset to a socket.
*/
public function writeSixel($conn, string $sixelData, int $terminalWidth): void
Image Acquisition
Local hosted images ? URLs matching the site's own origin (Config::getSiteUrl()) or the pattern /echomail-images/? are resolved by reading directly from data/echomail-images/ on disk. The URL is parsed to extract the username and slug (or legacy hash), and FileAreaManager::getMarkdownImageBySlug() / getMarkdownImageByHash() is called to resolve the filesystem path. This avoids an HTTP round-trip and works even when the web server is not accessible from the PHP CLI context.
External URLs ? fetched via file_get_contents() with a stream context that sets:
- timeout ? configurable, default 10 seconds.
- max_redirects ? 3.
- user_agent ? BinktermPHP/<version>.
- Content-Length is checked against the configured max size before reading. If Content-Length is absent or the download exceeds the limit mid-stream, the fetch is aborted.
Only http:// and https:// schemes are allowed. Any other scheme (file://, ftp://, etc.) is rejected before fetching.
Conversion via img2sixel
img2sixel is invoked via proc_open():
$cmd = [
$img2sixelPath,
'--width=' . $maxWidth, // scale to terminal width
'--colors=256',
'-', // read from stdin
];
Image bytes are written to stdin; stdout is collected as the Sixel data; stderr is captured for error reporting. The process is given a CPU timeout via stream_set_timeout() on the pipe handles. If the process does not exit within the allowed time, it is killed with proc_terminate().
Binary discovery ? SixelImageRenderer resolves the img2sixel path at construction time using the following strategy:
-
If `IMG2SIXEL_PATH` env var is set and non-empty, use it directly.
-
Otherwise, probe these known installation paths in order and use the first one that exists and is executable:
- `/usr/bin/img2sixel`
- `/usr/local/bin/img2sixel`
- `/opt/libsixel/bin/im2sixel`
-
If none are found, mark the renderer as unavailable.
private static array $SEARCH_PATHS = [
'/usr/bin/img2sixel',
'/usr/local/bin/img2sixel',
'/opt/libsixel/bin/im2sixel',
];
If the binary cannot be located or returns a non-zero exit code, the viewer shows an error line instead of the image and returns the user to the message.
Writing Sixel Data to the Terminal
Sixel data is a DCS sequence: ESC P ? q <data> ESC \. After writing it, the cursor position is undefined in most terminals. We follow the output with:
\033[?80l // disable sixel scrolling (optional, prevents blank line after image)
\r\n // move to next line
For terminals that support sixel scrolling the cursor lands below the image automatically. For those that don't, the explicit newline ensures the prompt that follows is on a clean line.
Integration Points
EchomailHandler / NetmailHandler
In displayMessage() (and the netmail equivalent), after fetching the message body:
-
Call `TerminalMarkupRenderer::extractImageRefs($markupFormat, $body)` to get the image list.
-
Pass the list into `buildView` closure so the status bar and body rendering can use it.
-
Pass `$imageRefs` to `TelnetUtils::runMessageViewer()` via a new optional parameter.
Inside runMessageViewer(), add handling for:
- Key I (single image) ? calls showImageViewer($conn, $state, $imageRefs[0]).
- Keys 1?9 (multiple images) ? calls showImageViewer($conn, $state, $imageRefs[$n - 1]).
showImageViewer() is a new helper on TelnetUtils (or EchomailHandler) that:
1. Clears the screen.
2. Prints a header: Image N of M: <alt text> and the URL in dim.
3. Instantiates SixelImageRenderer and calls fetchImage() then convertToSixel() then writeSixel().
4. On failure, prints an error message in red.
5. Prints the "press any key" prompt.
6. Waits for a keypress and returns.
Status Bar Hint
buildView passes $imageRefs to TelnetUtils::buildStatusBar(). When count($imageRefs) > 0, an extra entry is appended to the status bar items:
['text' => 'I', 'color' => TelnetUtils::ANSI_RED],
['text' => ' View image', 'color' => TelnetUtils::ANSI_BLUE],
When there are multiple images the hint reads View image (1-N).
Image Viewer Flow
displayMessage()
?
?? buildView() ? wrappedLines contain [Image N: alt] placeholders
?
?? runMessageViewer() ? user reads message
? ?
? ?? user presses I / 1..9
? ?
? ?? showImageViewer($conn, $state, $imageRef)
? ?
? ?? SixelImageRenderer::fetchImage()
? ?? SixelImageRenderer::convertToSixel()
? ?? SixelImageRenderer::writeSixel()
? ?? wait for keypress ? return to runMessageViewer
?
?? user presses Q ? return to message list
Configuration
| Variable | Default | Purpose |
|---|---|---|
| IMG2SIXEL_PATH | (auto-detected) | Override path to the img2sixel binary; if set, skips auto-detection |
| SIXEL_IMAGE_MAX_BYTES | 5242880 (5 MB) | Maximum download size for remote images |
| SIXEL_FETCH_TIMEOUT | 10 | HTTP fetch timeout in seconds |
| SIXEL_CONVERT_TIMEOUT | 15 | img2sixel process timeout in seconds |
| SIXEL_PIXELS_PER_COL | 9 | Pixels per terminal column used to calculate --width for img2sixel |
All read via Config::env('VAR', 'default').
The effective --width passed to img2sixel is derived from the live terminal column count:
$maxWidth = min(1600, (int)($state['cols'] ?? 80) * (int)Config::env('SIXEL_PIXELS_PER_COL', '9'));
This scales naturally: 40 cols ? 360 px, 80 cols ? 720 px, 132 cols ? 1188 px. A hard ceiling of 1600 px is applied to prevent absurd output on very wide terminals.
The feature can be disabled by setting IMG2SIXEL_PATH to an empty string, which causes SixelImageRenderer to report the binary as unavailable and display an error to the user if the image key is pressed.
Caching
To avoid re-fetching and re-converting the same image multiple times within a session (e.g., the user views the same image twice):
-
A simple in-memory cache (PHP array on `SixelImageRenderer`) keyed by URL stores the Sixel byte string for the duration of the session.
-
No disk cache is implemented in this phase ? each new session re-fetches. This avoids stale image issues and simplifies cleanup.
A disk cache could be added later if re-fetching proves noticeably slow for large, frequently-viewed images.
Security Considerations
SSRF prevention ? Before fetching any external URL, SixelImageRenderer::fetchImage() validates the URL:
- Scheme must be http or https.
- The hostname must resolve to a public IP address. Addresses in RFC-1918 ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16), loopback (127.0.0.0/8), link-local (169.254.0.0/16), and IPv6 link-local/loopback are blocked.
- DNS resolution is performed by PHP before the connection is opened, and the resolved IP is checked against the blocklist (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)).
Command injection ? img2sixel is invoked via proc_open() with an array argument, never via shell_exec() or string interpolation. Image data is piped through stdin, never placed in the command line.
Resource exhaustion ? Size and timeout limits (see Configuration) prevent a malicious or large image from stalling the session or consuming excessive memory.
Content-Type check ? The HTTP response Content-Type header must begin with image/ before reading the body. Responses with other content types are rejected.
Netmail Parity
Netmail messages can also contain markdown with inline images (when the markup format is markdown). Image fetching and viewing is available in netmail on the same terms as echomail ? including external URLs. The same extractImageRefs(), SixelImageRenderer, and viewer flow are used with no special-casing. The implementation should be shared.
Implementation Phases
Phase 1 ? Core infrastructure
-
Add `TerminalMarkupRenderer::extractImageRefs()`.
-
Update `renderMarkdown()` to emit `[Image N: alt]` placeholders instead of collapsing image links.
-
Implement `telnet/src/SixelImageRenderer.php` with fetch, convert, and write methods.
-
Add configuration env vars.
Phase 2 ? Viewer integration
-
Update `EchomailHandler::displayMessage()` and `NetmailHandler::displayMessage()` to extract image refs and pass them to the viewer.
-
Update `TelnetUtils::runMessageViewer()` to handle `I` / `1`?`9` keypresses when image refs are present.
-
Implement `showImageViewer()`.
-
Update `TelnetUtils::buildStatusBar()` to accept the image hint items.
Phase 3 ? Polish and error handling
-
Graceful error display for: `img2sixel` not installed, network timeout, SSRF-blocked URL, unsupported content type, conversion failure.
-
Log all image fetch/convert attempts to `server.log` at `debug` level, failures at `warning`.
-
Mention feature in `UPGRADING_x.y.z.md` with prerequisite note to install `libsixel-bin`.
Resolved Design Decisions
| Question | Decision |
|---|---|
| Full-screen vs. text-width viewer | Full screen ? the screen is cleared and the image fills the available terminal width. |
| Admin toggle for external URL fetching | Out of scope for now. |
| Allow image fetching in netmail | Yes ? same rules as echomail. |
| Narrow/wide terminal scaling | Auto-scale: terminalCols × SIXEL_PIXELS_PER_COL, capped at 1600 px. |
| img2sixel binary discovery | Probe /usr/bin/img2sixel, /usr/local/bin/img2sixel, /opt/libsixel/bin/im2sixel in order; override via IMG2SIXEL_PATH env var. |
| Sixel detection gate | Not enforced ? image viewing is available to all terminal users. May be revisited. |
|