Three defects in the inline completion menu, all of which produced something
wrong rather than merely unhelpful.
A candidate was inserted into the command line verbatim. A directory named
`My Documents` completed to `cd My Documents/`, which the shell resplits into
two arguments and the command breaks. `shell_escape_path` already existed for
drag-and-drop paths; completion never reached for it. `escape_candidate` wraps
it and keeps a leading `~/` unescaped, since that prefix is the user's own text
and escaping it would stop the home expansion it was typed for.
The same escape decides whether a common-prefix step is safe to write. The
prefix shared by `My Documents` and `My Music` is `My ` — writing it raw both
breaks the line and puts a space inside the open word, which closes the menu on
the next keystroke and leaves the user worse off than before the Tab. A prefix
that needs escaping now steps through the candidates instead.
A menu fed only by generators stayed armed forever when nothing matched.
`git ckout<Tab>` matches no subcommand, but git's alias generator is in flight,
so the session opens empty and waits — and the callback that would have closed
it returned early on an empty result, so the menu never learned the generator
was done. An armed empty menu swallows every later Tab instead of handing the
line to the shell. Sessions now count their generators, and the last one to
answer closes a menu that still has nothing in it.
Command completion scanned this machine's PATH in a remote pane. The remote
isolation added in 08ca3a3 covered paths and generators but deliberately left
command completion running, which was right for the builtins half and wrong for
the PATH half: `system_prof<Tab>` over SSH to Linux offered macOS's
`system_profiler`. Worse, it failed inconsistently — with no local match the
position falls through to the remote's own compsys and answers correctly, so
the bug only appeared when this machine happened to have a match. Builtins are
true on any POSIX shell and still go out; the PATH scan is now local-only.
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
The rcfile tty7 hands to bash replays the login-shell startup chain, but
sourced ~/.bashrc unconditionally after it. A login shell never does that
on its own — ~/.bashrc arrives only because the profile that won the chain
forwarded to it, which is how nearly every ~/.bash_profile is written. The
result was the user's whole ~/.bashrc running twice per pane: banners
printed twice, completions were sourced twice, and appends to
PROMPT_COMMAND stacked up.
Move ~/.bashrc into the same first-match-wins chain. That fixes the double
source and still keeps the fallback for a $HOME with no profile at all.
The existing test only asserted the rcfile mentions ~/.bashrc, which the
buggy version satisfied too. Add one that runs real bash against a
throwaway $HOME whose .bash_profile forwards, and counts the sourcings.
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
$SHELL is a snapshot the session inherits at login, so chsh never moves
it -- a GUI launch keeps reporting the shell that was current when the
user logged in, and goes on doing so until they log out. The window's
shell menu marked the wrong entry "default" for that whole stretch.
Read the passwd entry instead, via getpwuid_r -- the reentrant form,
since getpwuid returns a pointer into a shared static another thread's
lookup can overwrite. $SHELL stays as the fallback for the rare case
where the lookup fails. The three callers that each reached for the
variable on their own -- the default-name lookup, the PATH enrichment
that runs the login shell at startup, and the shell-integration kind
probe -- now share the one function.
Same commit fixes who wins a name in the menu. Candidates were login
shell, then /etc/shells, then $PATH, and dedupe keeps the first -- so
on a machine with a Homebrew bash, /etc/shells listing /bin/bash first
handed the entry to macOS's 3.2 from 2007, old enough that
bash-completion 2.x will not load against it. Probe $PATH before
/etc/shells and widen the probe list to the POSIX shells, so the menu's
"bash" is the binary typing bash would reach; /etc/shells still catches
anything installed off $PATH.
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
When a pane inherits no locale at all — the usual case for a GUI-launched
process on macOS — we derive an installed UTF-8 locale and inject it. But
we injected it as LC_CTYPE, which backs only character handling. Collation,
time and numbers stayed at C, and a shell that asks setlocale(cat, "") per
category finds no variable for the rest: bash warns
setlocale: LC_COLLATE: cannot change locale ()
once per category on every launch. zsh and fish swallow the failure, so
they merely look fine while being just as half-configured.
LANG backs every category and still loses to any LC_* the user's own rc
files set afterwards, which is what a fallback should do. LC_ALL would also
cover everything but would override those.
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
Some Windows shell brokers enforce `ProcessRedirectionTrustPolicy` on what
they launch. The daemon inherited it, every ConPTY shell under the daemon
inherited it in turn, and PowerShell could then no longer traverse a
user-created junction — which is exactly what Scoop's `current` links are.
`oh-my-posh` and `fzf` died with `Shim: Could not determine if target is a
GUI app`. Windows Terminal was unaffected because its process tree never
picked the policy up.
The policy cannot be relaxed once enabled, so the fix is to not inherit it:
when tty7 detects the enforcing bit, it creates the daemon with
`STARTUPINFOEXW` and `PROC_THREAD_ATTRIBUTE_PARENT_PROCESS` naming the
interactive desktop shell, which supplies the ordinary desktop token, device
map, and mitigation policy. The Win32 code stays isolated in
`daemon/spawn/windows.rs`, and the ordinary path still runs whenever the
policy is absent — or whenever the desktop shell cannot be borrowed, in
which case tty7 logs a warning and starts degraded rather than not at all.
Because naming a logical parent makes handle inheritance follow that
process, the daemon starts with no standard handles. `daemon::server` and
the pane reader's trace line now write to stderr in a way that tolerates
that, instead of `eprintln!`, which panics on a failed write.
ConPTY exit ordering: the process-exit monitor could observe a short-lived
shell exiting before the reader had delivered its final frame, so `Exited`
reached clients ahead of the output that preceded it. The monitor now
releases the pseudoconsole and lets the reader — which reports only after
forwarding everything up to EOF — announce the death, with a bounded window
behind it for the case where EOF never arrives because a grandchild holds
the ConPTY output pipe open.
Note this changes the daemon's token on the clean-parent path: it derives
from Explorer, so an elevated tty7 starts a medium-integrity daemon.
Co-authored-by: ARNO <ArnoChenFx@users.noreply.github.com>
capture_plain_returns_text_not_escapes gated its byte-level asserts on
the marker reaching the rendered capture, then asserted the raw capture
already carried a CR. The two captures are separate snapshots taken in
sequence, and on Windows ConPTY re-emits the echoed command in
escape-laden bursts: the marker can render (from the typed input line)
while the slightly earlier raw snapshot has yet to see a single CR —
Enter's CRLF only arrives with the command's execution. CI hit exactly
that window on x86_64-pc-windows-msvc.
Make the CR part of the settle condition the loop polls for, alongside
the marker, and name both in the timeout message so a genuine
CR-stripping regression still reads as one.
Co-authored-by: l0ng-ai <ysdpk123@gmail.com>
Opens an Unreleased section for what landed after 26.8.1. The PR body
these came in under described a session CLI that had already shipped in
26.8.1 (#274) — `spawn`, `kill`, wire changes to `PaneInfo`, new control
kinds — none of which is what actually merged, so the changelog is
written from the diff instead.
`wait` is documented in its final shape, `--changed` included: the status
is a level rather than an event, so a wait issued right after a `send`
would otherwise answer with the previous turn's state. That is the part
a reader has to know to use the verb correctly, not a footnote.
The taught loop started its worker with `claude -p`, but headless print
mode never stops to ask, so the `waiting` state steps 3-4 are built on
could not arrive. Step 2 now launches interactively.
Every wait after a send passes `--changed`, with the reason spelled out:
without it the loop re-reads the state it just walked in on. The exit-1
"worker died" branch is documented too, and both guards are asserted in
the skill-content test.
Also gives the install round-trip test a Drop guard, so a panic cannot
leave `CLAUDE_CONFIG_DIR` set for whatever runs next in the process.
`installed()` was called from inside `render_settings_agents`, so the
Agents page did a `read_to_string` every frame it was on screen. The hook
rows it was modelled on cache into `SettingsState` precisely to avoid
that; the skill's presence now does the same, read when the page opens
and after a change.
The switch also swallowed its outcome into `log::warn!`. The one error a
user actually hits — uninstall refusing a `tty7-orchestration` file tty7
did not write — left the switch springing back with no explanation. The
result now lands in `orchestration_skill_note` beside the switch, the way
`agent_hooks_note` does, and the install/uninstall itself runs off the UI
thread.
Three holes in the wait primitive, all of which make a delegation loop
answer with something other than what it asked for.
The agent status the server keeps is a level, not an event: `done` stands
until the next turn begins, `waiting` until the agent moves again. A wait
issued right after a `send` therefore returned last turn's state before
the worker had even read the input, and a second task in the same pane
matched `--until done` instantly. `--changed` snapshots the position the
wait arrived at — status plus the activity counter, which ticks even when
the status letter does not — and refuses to match it; the JSON carries
`stale` so a plain wait can tell whether the answer might be a leftover.
`exit` was unreachable for any pane that had ever had an agent: the
snapshot has no liveness in it, and the daemon keeps a dead pane
registered until it is closed. A worker that crashed mid-turn reported
`working` until the timeout. Liveness is now re-checked from the tree
every few polls; the fast path where the first poll already answers still
costs exactly one request.
The "pane exited before reaching the awaited state" branch built its JSON
and then threw it away on an anyhow error, leaving `--json` with nothing
to read and the exit indistinguishable from an unreachable daemon. It now
exits 1 with its report.
Also: the wire spelling of a state is written out instead of derived from
the variant name, the sleep no longer overshoots a near deadline, an
absurd `--timeout` cannot overflow, and `--interval` is range-checked at
parse time rather than silently clamped.
* feat(cli): `tty7 wait` + the agent-coordination note
The two pieces of the original session-CLI PR that main's own CLI
doesn't cover, rebuilt as a minimal delta against it.
`tty7 wait %N --until waiting,done --timeout 600` blocks until a pane's
agent reaches a requested state — the orchestration primitive that lets
one agent sleep until its peer blocks on a permission prompt or
finishes a turn, instead of screen-scraping. A poll of `AgentStates`
rather than an `events` subscription on purpose: a one-shot stateless
question composes into scripts, survives a server restart mid-wait, and
needs no cursor management. Agentless-but-live panes read as idle via
the machine tree; a dead or vanished pane reads as exit, which ends
every wait (matched only when asked for). Timeout exits 124, the
`timeout(1)` convention.
The coordination note is discovery for the whole CLI: a marked,
idempotent block describing the verbs, installed into
~/.claude/CLAUDE.md (always; CLAUDE_CONFIG_DIR honored) and
~/.codex/AGENTS.md (only when ~/.codex exists). A one-time "Let your
agents coordinate?" prompt fires the first time a pane detects a coding
agent; a Settings → Agents switch drives the same install/remove, with
state read from the files themselves. Uninstall strips exactly the
marked block; an unterminated block is left alone rather than truncated
at a guess.
* feat(agents): replace the global note with an orchestration skill
Per review: global instructions tax every session's context and hand
every agent — workers included — the ambient authority to orchestrate
its neighbours. The common shape is primary → workers: one agent owns
decomposition, dispatch, waiting and aggregation; workers just do
bounded tasks.
A Claude Code skill fits that exactly. `core::orchestration_skill`
installs ~/.claude/skills/tty7-orchestration/SKILL.md — only its
one-line description rides in context until the user or the primary
agent explicitly invokes it, and workers never see it. The body can
therefore afford the full delegation loop (tab new → send → wait →
answer-or-capture → pane close) instead of a token-starved cheat
sheet.
The file is wholly tty7-owned: install is a plain overwrite (also the
version-refresh path), and uninstall keys on an ownership marker so a
user's hand-written skill under the same name is refused, not deleted.
Gone with the global note: the first-agent-detected prompt, its config
flag, and the CLAUDE.md/AGENTS.md writers — the Settings → Agents
switch now drives the skill install instead.
---------
Co-authored-by: l0ng-ai <ysdpk123@gmail.com>
Replace the unbounded decode channel with a bounded, latest-frame inbox so full-window browser frames cannot queue faster than they decode. Keep deletes ordered with in-flight work and discard superseded frames before decoding them.
Only retire images that reached the sprite atlas, and evict remaining atlas entries when a pane closes. This keeps hidden terminal-browser tabs and repeated pane lifecycles from retaining one decoded frame per repaint.
26.8.1 panics on launch under Wayland on a VMware Ubuntu guest:
gpui_linux/src/linux/wayland/client.rs:924: RefCell already borrowed
The xdg-desktop-portal event source notified windows of the initial
color-scheme and button-layout replies while still holding
`client.borrow_mut()`, and those callbacks re-enter GPUI, which reaches
the same `RefCell` through `with_common`. Whether it fires depends on
whether the portal reply beats window creation, so a slow VM loses that
race every time.
Fixed in the fork (l0ng-ai/zed@3a4acfd) for both the Wayland and X11
clients by collecting the window pointers and dropping the borrow before
notifying. Windows and macOS never compile that crate.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Cursor-hiding TUIs (Kimi CLI, Ink apps) draw their caret as a reverse-video
cell and leave the real cursor wherever the frame's last write ended — for
Kimi that is the input box's right border, and the IME candidate list was
stranded there. When the cursor is hidden and its row holds exactly one
caret-sized inverse run, snap the IME anchor (and the marked-text preview)
to that run; rendering is untouched.
The gpui side (bumped here) now also answers IMR_QUERYCHARPOSITION — the
query the Windows 11 Microsoft Pinyin IME uses instead of CANDIDATEFORM —
and re-anchors the candidate window on every WM_IME_COMPOSITION.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
pane_title_of() picked any non-empty PaneRecord.title, but an idle
terminal's foreground process is just the shell itself (zsh, bash, ...).
That made almost every idle-shell workspace show up as "zsh" in the
sidebar instead of the far more useful cwd/repo-derived name, which
defeats the "cwd as final fallback" intent of this change.
Skip bare shell process names when picking a pane title, so the
process-name fallback only kicks in for genuinely distinctive
foreground processes (nvim, an agent, ...).
Restarting the local server clears the window's tabs and then pulls the
layout back from the machine tree. The pull went out on the control link
we held, which pointed at the server we had just killed: `is_connected`
only flips once the reader sees EOF, so for a moment the dead link still
classifies as Ready. The call on it failed, `finish_hydration` logged a
warning and gave up, and the window stayed empty on the home page.
Drop the local link before resyncing so the pull waits for the new
server instead of racing a dead socket, and record a failed hydration as
a debt the next sync retries. The debt also stops the empty window from
diffing into "close every tab" and wiping the layout off the machine
once the link is back; a `Replace` retry is abandoned if the user has
filled the window in the meantime.
The CLI's own --help calls it "built for coding agents", but `capture` handed
back the daemon's raw PTY bytes, which is the least readable thing it emits,
and every verb panicked when its reader hung up.
`capture --plain` replays those bytes through a terminal grid instead of
stripping escapes from them, using the same alacritty_terminal rev the GUI
renders panes with. The difference is not cosmetic: only the grid knows that a
break at the pane's width was a wrap rather than a newline, that a CR meant
"overwrite this line" rather than "end it", and which cell a wide char shares
with its spacer. A regex gets the easy 90% and then invents the rest — on one
real pane it turned 1193 lines into 2806.
The size each segment needs comes for free: the daemon already sends
DaemonMsg::Size right before every Snapshot, and the CLI was discarding it.
Panes here measure 249 and 86 columns, so the hardcoded 120 would have wrapped
both in the wrong places. Observing still resizes nothing.
The pipe fix is two mechanisms with one contract. On Unix SIGPIPE goes back to
its default disposition, which covers every write site at once and ends the
process the way it ends `cat` (141). Windows has no such signal, so stdio::out
recognizes the hung-up write and leaves quietly. Before this, 16 of 19 verbs
printed a panic and a backtrace note for `tty7 ls | head -1`; `run` instead
reported it as a failure with exit 1.
Also adds skills/tty7, the Claude skill for driving this CLI. It shipped with a
Python ANSI stripper, which is what prompted --plain; the script is gone.
alacritty_terminal moves to [workspace.dependencies] so the GUI and the CLI
cannot drift onto two revs of the fork.
a_slow_stream_outlives_its_idle_timeout proved that the idle window
resets per message by having a thread sleep 60ms between sends and
trusting that to stay under a 150ms timeout. That is a 90ms margin
against the OS scheduler, and the darwin CI job lost the bet.
It also tested the wrong thing: whether recv_timeout fires after the
deadline is the standard library's contract, not ours. What is ours is
that the loop restarts the window on every message rather than budgeting
the whole stream.
Make the one blocking call injectable and script it. Both timing tests
now feed drain_git_stream a fixed sequence -- no threads, no sleeps, no
wall clock -- and the pair runs in microseconds. The queue-budget tests
keep their real channel, which is what they are about. Rename the second
test to say which property it holds.
Follow-up on the review of #277. Seven fixes, no change to what the feature
is for.
An AppImage copy is now claimed with a marker file instead of being inferred
from "am I an AppImage right now". Keying off the runtime meant that a user
who moved from the AppImage to the tarball hit their own copy, read it as
somebody else's binary, and never got another install for as long as that file
sat there.
The Windows uninstaller takes {app} back out of HKCU\Environment. Nothing did
before: the entry is written by the app at runtime, so Inno never knew it
existed and every uninstall grew the user's PATH by one dead entry. Unix has no
equivalent hook and still leaves its symlink behind; that is now stated in the
module docs rather than left to be discovered.
An occupied candidate directory no longer ends the scan, and every platform now
reports whether the install actually wins the lookup. `Occupied` on
/opt/homebrew/bin used to mean giving up while ~/.local/bin sat free, and
Windows — which appends to PATH and so never collides — reported `Installed`
even when an existing tty7 earlier on PATH kept beating it. A new
`InstalledShadowed` names the winner.
`cargo run --release` no longer repoints the developer's real tty7 at a build
tree. `cfg!(debug_assertions)` only covered the debug half of that.
The Windows registry PATH is read, matched, and written as UTF-16 throughout.
It went through `to_string_lossy` before, so a value the registry holds but
Rust cannot represent as a String would have been written back with U+FFFD in
place of its characters — the exact PATH corruption the surrounding code is
careful to avoid.
Two tests mutated $HOME and $PATH while the rest of the binary's tests ran
beside them, and src/ui/home.rs mutates $HOME too. `candidate_dirs` takes home
as a parameter, `place` takes its mode, and the PATH-joining and registry-
joining rules are pure functions — so no test in this module touches the
environment any more. 5 tests become 11, and the Windows joining logic is
covered on every platform.
Also: the config flag reaches Settings → About and both features docs instead
of being config.json-only, startup reads config.json once instead of twice, and
the CLI's strip failure warns like its sibling instead of being swallowed.
The `tty7` CLI was built by every release run and thrown away: all four
bundle scripts copied only `tty7-app`, and the upload glob covers `dist/`,
which the CLI never reached. Nothing put it on PATH either, so the
agent-facing half of the product was unreachable from a shipped install.
Bundle it on all four platforms, and have the GUI link it up itself rather
than hiding the step behind a menu item most people never find.
The install has two halves. The environment half prepends the CLI's
directory to this process's PATH before the daemon is spawned, so every
pane inherits it — that alone makes `tty7` work where agents actually run,
writes nothing to disk, and behaves the same everywhere. The on-disk half
symlinks into a directory already on PATH (Unix) or appends to
HKCU\Environment (Windows), and is allowed to fail.
Candidate directories are a fixed list intersected with PATH, not the first
writable entry on it: pyenv/rbenv/asdf/mise shim directories sit at the
front of PATH on many machines and are writable, and anything dropped there
is deleted on the next rehash — silently, days later.
Debug builds get the environment half only. `target/debug` holds a `tty7`
too, so otherwise a `cargo run` would repoint the developer's real `tty7`
at a debug binary, and each isolated dev-verify instance would rewrite the
PATH of the machine it is meant to stay away from.
Listing WSL distros shells out to wsl.exe with no time limit, so a WSL
service that is starting up, updating, or simply wedged blocks the whole
shell probe -- and with it the menu of shells the window offers. The
Windows CI job hit the slow end of this often enough to flake.
Add proc::output_within, which spawns the child, drains both pipes on
their own threads so a large write cannot deadlock the reaper, and kills
the child once the deadline passes. Give the distro listing three seconds
of it; a machine that cannot answer by then simply reports no distros,
which is what an unreachable WSL already produced.
The Windows CI job flakes on a_local_window_lists_this_computers_shells:
the local shell probe runs off-thread and, on Windows, spawns wsl.exe to
list distros. With the whole test binary running in parallel on a slow
runner that can take longer than the five seconds pump_until allowed, so
the poll gave up on a probe that was merely late.
Raise the deadline to thirty seconds, matching the other polling tests in
this repo. A probe that does land still returns immediately, so a healthy
run costs nothing extra.
Both socket_path_for and socket_path_in live behind cfg(unix) — Windows
serves the control channel over a named pipe. The test referencing them
broke the Windows lib-test build. Gate the test, and narrow the
socket_path_in re-export to test builds so it stops warning as unused.
`tty7 tree` answered "launching the GUI is not wired up yet (would open tree)".
Bare `tty7 [PATH]` opens the GUI, so any word clap has no subcommand for lands
in that positional — every typo came back as an offer to open a directory named
after it.
A word with no separator, no leading ./~, and no matching file on disk is a
mistyped verb, and is now reported as one. Real paths still reach the launcher
and fail there for the honest reason.
Also: `ws`'s help said "the named session trees", missed by the session -> shell
pass, and long_about still advertised $TTY7_SOCKET, which no longer exists —
it is $TTY7_CONFIG_DIR now.
spawn_once hard-coded `workspace: None` — the field was added to make the call
compile when the protocol grew it, and never wired up. Every pane the GUI opens
therefore reached its shell without $TTY7_WS, and the GUI is how panes are
normally opened: `tty7 ws tree`, `tab ls` and `run --keep` all refused inside
one, and `doctor` reported the workspace as missing.
The id was already there — `owner` carries it and is passed straight through.
It is taken separately rather than reused after the filter below it: `owner` is
also gated on FEATURE_PANE_OWNER, while the workspace field rides the c4p5 spawn
kind and needs no feature probe. Local routes only, matching `owner`, since a
remote server keeps its own machine tree and this id names a workspace in ours.
Extracted as spawn_workspace so a test pins it; passing None again would be
invisible otherwise.
Manual testing found `tty7 run`, `send`, `capture`, `procs` and `split` broken
against any normally-installed server — the CLI's entire hot path. Only the
control verbs worked.
Two endpoints, two rules. The pane socket came from the config dir; the control
socket ignored it and sat in $XDG_RUNTIME_DIR/tty7 or ~/.local/share/tty7 —
under the same basename, `daemon.sock`. So they were told apart by directory
alone, and the CLI, handed one path in TTY7_SOCKET, reconstructed the other with
with_file_name: on the default layout that returns the input unchanged. Pane
verbs dialed the control socket and the daemon hung up on them. A --config-dir
server was worse: it published the *default* control socket to the shells it
spawned, so a CLI inside an isolated instance drove a different server.
The e2e suite passed throughout because its harness set TTY7_CONTROL_SOCK
explicitly, placing both endpoints in one directory under different names — a
layout production never produces. It had removed the bug's precondition.
Now: the control socket is derived from the config dir like the pane socket
(control.sock beside daemon.sock, mirroring Windows' control.port/daemon.port,
with -control on the hashed fallback so the two cannot collide), and panes are
handed TTY7_CONFIG_DIR instead of a socket path. A CLI inherits it, so
ControlClient::connect and PaneClient::local resolve the same two sockets the
server opened, through the same functions. No second derivation to disagree.
remote_link's remote_control_socket was a third copy of the old rule, used to
locate a remote server's endpoint before connecting; it follows the config dir
too, and the env probe now reads $TTY7_CONFIG_DIR.
Drops the CLI's server-lifecycle guard: stop/start already follow the config dir
through transport::connect and --config-dir, so there is no longer a mismatch to
refuse. The e2e case that covered only `status` over a lone variable now also
runs a pane verb — the asymmetry it missed is exactly what broke.
Note: this moves the control socket for existing installs. A running pre-change
daemon will not be found at the new path, which is the honest outcome — its
control dialect is v3 against this build's v4, so reaching it only produced a
version error anyway.
Two conflicts, both where main's graphics work and this branch's observer work
touched the same lines.
daemon/protocol.rs: both sides appended frame kinds. INPUT_ACK (51) and
IMAGE/DELETE_IMAGE (60/61) do not collide; both kept.
daemon/pane.rs: main taught the reader to forward a chunk as an ordered
GraphicsFrame sequence instead of one Output, so an image lands at the cursor
cell the sender drew it at. This branch had lifted the same send into
fan_out_output, which also feeds read-only observers and holds each to its
budget. fan_out_output now takes the frame sequence: the no-graphics fast path
still sends one Output, and Image frames reach observers as well, gated on their
own length. A Delete selector rides `notify`, which is ungated but still drops
an observer that has stopped draining — matching the drain accounting in
server.rs.
An observer is a read-only mirror of the pane, so it sees images for the same
reason it sees text.