Skip to content

[WIP] ivrix-prompt-selection-edit-app - #7

Draft
DananzMolt wants to merge 1978 commits into
mainfrom
ivrix-prompt-selection-edit-app
Draft

[WIP] ivrix-prompt-selection-edit-app#7
DananzMolt wants to merge 1978 commits into
mainfrom
ivrix-prompt-selection-edit-app

Conversation

@DananzMolt

Copy link
Copy Markdown
Owner

Parked by open-wip-prs.sh on 20260802-234157.

29 commit(s) that existed only on this machine. Opened so the work can be
continued elsewhere and the local worktree freed.

Resume: git fetch origin && git switch ivrix-prompt-selection-edit-app

cmux reload-cloud and others added 30 commits July 24, 2026 03:47
…all teardown (manaflow-ai#8858)

* Add failing regression tests for unbounded mobile event emission (manaflow-ai#8842)

A stalled, never-draining terminal.render_grid subscriber must keep the
host's pending event queue bounded WITHOUT tearing the connection down:
close-on-overflow churns connection resources (sockets, lanes, tasks)
every few seconds for as long as the subscriber stays slow, which is the
reconnect-churn half of the manaflow-ai#8842 field incident (785 -> 3,200 fds,
4.12 GB RSS, jetsam largestProcess, forced hard reset).

Red on this commit:
- testStalledRenderGridSubscriberStaysOpenWithBoundedEventQueue: the
  connection currently closes at bounded capacity instead of shedding
  recoverable render-grid frames.

Green guards committed alongside (documenting contracts the fix must
preserve):
- testStalledSubscriberOverflowOnNonRecoverableTopicClosesConnection:
  non-recoverable topics (mobile.sync.delta) keep close-on-overflow.
- testCloseReleasesConnectionAndTransportResources: every
  per-connection resource releases after close, even with a send
  stalled mid-flight.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Bound mobile event emission with synchronous admission and shed-or-close (manaflow-ai#8842)

Root cause (owner boundary): MobileHostService.emitEvent spawned one
unstructured Task per registered connection per event, each retaining
the full [String: Any] payload, and each connection actor re-serialized
the same payload before running its bounded-queue admission check. The
bound therefore applied only to encoded frames; everything upstream of
it — task allocations, retained payload graphs, the actor mailbox — was
unbounded whenever a subscriber drained slower than the producers.
Render-grid and terminal-bytes events are continuous (every Ghostty
tick x every surface, ~130 surfaces in the field incident), so a slow,
paused, or half-dead phone grew host memory and CPU without bound
(4.12 GB RSS, 69,946 s CPU, jetsam largestProcess, forced hard reset).
Overflow policy was connection teardown, so a slow-but-alive subscriber
cycled connect -> fill -> close -> reconnect -> full replay, churning
NWConnection/Iroh lane resources (785 -> 3,200 fds).

Invariant now enforced: memory, tasks, and connection resources
attributable to mobile event emission are O(bounded queue capacity) per
connection regardless of subscriber behavior, and every per-connection
resource is released deterministically when a subscriber stops draining.

Mechanism:
- Encode once, admit synchronously: emitEvent encodes the envelope a
  single time and admits it into each connection's new
  MobileHostConnectionEventQueue (lock-protected, count- and
  byte-bounded) on the emitter's thread. No per-event tasks; at most
  one drain task per connection, claimed through the queue.
- Shed instead of close for recoverable topics: render-grid frames are
  shed per surface under overflow; the surface is poisoned against
  further deltas (the iOS client has no delta-continuity check, so a
  silently dropped delta would corrupt its grid invisibly) until the
  producer — asked via MobileTerminalRenderObserver's coalesced
  full-resync hook — re-emits a full frame that re-bases every
  subscriber's chain. terminal.bytes (client detects seq gaps and
  replays) and terminal.updated/workspace.updated (level-triggered
  pings) shed without extra recovery. Non-recoverable topics keep the
  close-on-overflow contract.
- Deterministic teardown for half-dead peers: control-lane event writes
  now run under a bounded stall deadline (default 30 s, injectable) —
  a peer that accepted the connection but stopped reading previously
  pinned the drain, queue, transport, and tasks forever. The Iroh
  independent event lane already had its own 3 s deadline.
- Render-grid CPU: the frame is JSON-encoded once and spliced into the
  event envelope, replacing the encode -> parse -> re-serialize round
  trip that showed up in the incident's cpu_resource stacks.

Turns the commit-1 regression test green and adds fan-out, stall-
deadline, and queue-admission policy coverage.

Fixes manaflow-ai#8842.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Drop new ForTesting seam; read the internal event queue directly in tests

Review feedback: the queue is an internal nonisolated let, so the test
reads eventQueue.count/byteCount via @testable import instead of a new
debug wrapper.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: cmux reload-cloud <cmux-reload-cloud@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…anaflow-ai#8221)

* Mac side: mobile.workspace.changes.* RPCs backed by CmuxGit WorkspaceChangesService

Adds a subprocess-backed workspace-changes service (summary/files/file_diff
vs merge-base of the default branch, untracked included, 15s summary TTL
cache, path containment validation, 400KiB/6000-line hunk-aligned diff
truncation) and exposes it to the phone as three mobile data-plane RPCs
with capability workspace.changes.v1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* iOS data layer: changes DTOs, CmuxMobileChanges parser package, composite integration

Lenient DTOs for the three mobile.workspace.changes RPCs, a Foundation-only
CmuxMobileChanges package (unified-diff parser with line numbering and CRLF
preservation, grapheme-safe intra-line emphasis, clamped diff font
preference), and MobileShellComposite integration: workspace.changes.v1
capability gate, 64-id batched summary fetches with a 15s reuse window,
rpcWorkspaceID-keyed chip snapshots, and a cancellable 250ms debounce off
list refreshes and workspace.updated events.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* iOS UI: Changes sheet — file list, swipe-paged diff viewer, preview route

Value-driven Changes screens in CmuxMobileChanges (GitHub-calibrated
adaptive theme, summary header, status-glyph file rows with mini add/delete
bars, dual-gutter soft-wrapped unified diff with intra-line emphasis, page
TabView with position pill, pinch font sizing, copy line/hunk), the ShellUI
sheet mount with parsed-document cache, and the deterministic
CMUX_UITEST_CHANGES_PREVIEW fixture route (populated/diff/empty/states).
All strings localized en+ja.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* iOS entry points: Changes toolbar button, workspace-list chips, one-time hint

One shared openWorkspaceChanges() action presents the Changes sheet from
the capability/connection-gated toolbar button (badge capped at 99+) and
the dismissible first-time hint banner; workspace rows get an ambient
+A −D chip fed by value snapshots keyed by rpcWorkspaceID. Also renders
'No newline at end of file' markers as dimmed gutter-less rows (parser
emits them; Copy Hunk excludes them). All new strings localized en+ja.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Keep wire DTO types out of ShellUI: map change status in the shell layer

CmuxMobileShellUI never imports CmuxMobileRPC; the status→FileChangeKind
mapping moves into CmuxMobileShell (which gains a CmuxMobileChanges
dependency) so the sheet consumes model values by member access only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Render an honest not-a-repository state in the Changes sheet

A workspace whose directory is outside any Git repository previously fell
into the generic connection-error state. The composite now maps the
not_a_repo RPC code onto a shell-owned WorkspaceChangesFetchError and the
list renders a dedicated localized state (folder.badge.questionmark, no
summary header, no retry). Verified live against the tagged Mac's home
workspace.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Fix macOS Debug build broken on main: explicit color capture in NSImage draw closure

Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowSlotViews.swift from
manaflow-ai#8034 references the slot view's
color property inside the escaping NSImage draw handler without explicit
capture, which fails to compile (CI is currently advisory, so it landed
unnoticed). Capturing the color value keeps the view out of the image's
retained draw block.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Render the changes chip in the shared WorkspaceRow so the UIKit list shows it

Main's UITableView workspace list (manaflow-ai#8186) hosts WorkspaceRow directly,
bypassing WorkspaceNavigationRow where the +adds −dels chip lived, so chips
vanished after merging main. The chip (and its localized accessibility
label) moves into WorkspaceRow itself, both pipelines pass it through, and
the table coordinator reconfigures exactly the cells whose chip value
changed. Also keeps concise changes.summary debug-log lines that made this
diagnosable from the container log.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Calm the file list: text badges instead of a status-icon zoo

Five leading pictograms in four hues made the list read as a barrage of
symbols. The row now leads with the path; magnitude stays on the counts and
mini-bar; and only exceptional states get a quiet capsule badge in the BIN
badge's language: green 'New' (added and untracked collapse into one
concept), red 'Deleted' with the whole path dimmed. Renames keep only their
old → new line. Modified rows, the common case, carry no marker at all, and
the palette drops to green/red (orange and blue status tokens removed).
Badges are localized en+ja; VoiceOver labels keep the full status wording.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Say what the app means: 'Binary' badge and a truncation footer that explains itself

'BIN' was insider shorthand; the badge now reads Binary (ja already said
バイナリ). The truncation footer stops announcing a mechanism and states the
tradeoff: 'Large diff. Showing the first N lines to keep things fast. See
the rest on your Mac.' The 6,000-line/400KiB per-file cap itself is
unchanged; it exists so generated files and lockfiles cannot balloon the
RPC payload or phone memory.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* View changed binary files with the artifact viewer, at either revision

Changed images, PDFs, and other binaries stop dead-ending at a placeholder:
the diff page's binary card offers View Before / View After (or a single
View File for added, untracked, and deleted files) and pushes the shared
ChatArtifactViewerDestination — zoomable images, PDFKit, AVKit, QuickLook —
fed by two new data-plane RPCs, mobile.workspace.changes.file_stat and
.file_fetch. Reads are authorized against the workspace's current
changed-file set (rename old paths only for revision=base) plus the path
containment check; base blobs materialize once via git show into an
actor-owned 256 MiB LRU temp cache and serve 3 MiB chunks with honest EOF
math. Loader cache scope keys by workspace + revision + path so before and
after never collide.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Log workspace-changes content failures to the container debug log

One line per failed stat/fetch with method, params, and the underlying
error, matching the changes.summary logging style, so preview failures are
diagnosable from the device log instead of a generic viewer state.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Binary previews render inline with their actions in place

Paging onto a changed image or PDF now shows the content immediately: the
page hosts a chrome-free ChatArtifactInlineViewer (new public component
reusing the pager's per-type hosts and lifecycle), with a Before | After
selector for modified and renamed files. The full-screen hop is gone; the
viewer toolbar's Share / Save to Files / Copy-image actions render in place,
conditional on the loaded content type, through a factored
ChatArtifactActionBar the full viewer now shares. Zoom coexists with page
swipes the way Photos does: a zoomed-out pan falls through to the pager.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Preview actions live in the sheet toolbar, conditionally for the current page

Share / Save to Files / Copy image move from floating pills into the
Changes sheet's top-trailing navigation toolbar, driven by an Equatable
descriptor the inline viewer publishes via a SwiftUI preference (execution
stays in the viewer through a registration-generation host, so a stale
page can't clear a fresh performer). Only the selected pager page mounts a
preview, so the toolbar always reflects the visible file and empties out
on text pages. Includes the fix that attaches the toolbar group to the
pushed pager screen, which owns its own navigation bar, instead of the
sheet root.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Copy image via standard glyph and image long-press menu

The copy-image action now uses the doc.on.doc copy symbol instead of the
gallery-reading photo-on-rectangle glyph, everywhere the shared
ChatArtifactAction metadata is consumed (Changes sheet toolbar and full
viewer). Long-pressing a rendered image presents Share, Save to Files, and
Copy image through a UIContextMenuInteraction on the hosted UIImageView,
routed through the same performers as the toolbar; the full viewer's
copy-image performer now actually copies the rendered image. Adds a
changes.hint debug-log line reporting hint eligibility inputs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Add Changes row to the workspace title menu

A second, non-toolbar entry point: the workspace title menu now has a
Changes row (shown whenever the host supports workspace changes) routing
through the same openWorkspaceChanges() action as the toolbar button. The
toolbar keeps the existing +/- icon button unchanged. Also factors the
list chip's +N -M text into a unit-tested WorkspaceChangesChipTextPolicy
that falls back to a localized file count for binary-only change sets.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Tappable list chip, counts in the Changes toolbar button, drop menu row

Three entry-point changes from the placement interview. The +N -M chip on
workspace-list rows is now a button that opens that workspace's Changes
sheet directly over the list (both the SwiftUI List and UIKit-table
pipelines; row selection untouched). The workspace-detail toolbar button
replaces its abstract +/- glyph with the same green/red counts whenever
the tree is dirty, falling back to the glyph when clean; counts resolve
their colors against the terminal theme's chrome scheme rather than the
system scheme so they stay legible on dark chrome. The title-menu Changes
row is removed as redundant.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Stack the toolbar Changes counts vertically

The workspace-detail toolbar button now stacks +N over -M so the counts
cost no more horizontal space than a plain icon button. The shared chip
label gains a stacksVertically variant used only by the toolbar; list
rows keep the horizontal layout.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Failing test: oversized single hunk truncates to nothing

A file whose first diff hunk alone exceeds the 6,000-line/400KiB cap
comes back as a header-only diff, which the phone renders as "Showing
the first 0 lines" with an empty page.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Split oversized first hunk instead of emitting an empty diff

When the first hunk alone exceeds the byte/line cap, emit as much of its
body as fits under a hunk header rewritten to describe the partial body
(start lines preserved, old/new counts recomputed), so the phone shows
the head of the change instead of "the first 0 lines".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Make diff laziness per-line so huge hunks scroll

The diff body lazily rendered per HUNK, so a single multi-thousand-line
hunk (e.g. a truncated 6,000-line rewrite) became one eagerly laid-out
child: seconds of layout and frozen/stuttering scrolling. DiffRowSnapshot
now flattens hunks into per-line rows and the LazyVStack iterates those,
so only visible lines lay out regardless of hunk shape.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Expand hidden unchanged lines GitHub-style in the diff reader

Hidden unchanged regions (above the first hunk, between hunks, after the
last hunk) now show tappable expander bands: 100-line steps, full reveal
when 120 or fewer remain, split up/down bands between hunks. Revealed
lines come from the current working-tree file over the existing
authorized chunked file_fetch path (fetched once per file, 5 MiB cap,
inline retry on failure) and render as per-line context rows with both
gutters mapped through the hunk offsets, preserving per-line laziness.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Progressive Show more past the diff cap and unified short-gap expanders

The 6,000-line/400KiB per-file cap becomes progressive: file_diff accepts
an optional max_lines (clamped 6,000...1,000,000 lines / 64 MiB abuse
guard, byte budget scaled proportionally) and reports diff_total_lines,
and the truncated footer becomes "Showing X of Y diff lines" with a Show
more button that requests 4x the current budget and replaces the document
in place (stable row IDs preserve scroll and expansion state). Expander
bands whose whole run reveals in one tap now render a single unified
button instead of a split up/down pair whose halves did the same thing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Address review findings: bounded work, truthful truncation, stable routes

Fixes the six accepted P1 review findings plus bot feedback:
- Clamp progressive diff responses to 6 MiB so they always fit the 8 MiB
  RPC frame; the client stops offering Show more when a larger budget
  stops growing the loaded window.
- Suppress the trailing context expander on truncated diffs (the region
  after the last included hunk is not known unchanged).
- Read git diff output through a bounded incremental reader (terminate
  past the budget) and report the diff total as unknown when cut short.
- Size base blobs with cat-file before materializing, stream git show to
  the temp cache incrementally, refuse blobs over the cache budget, and
  never pin an oversized entry through eviction.
- Parse diff responses off the main actor and cache the flat row
  projection in state instead of rebuilding it per body evaluation.
- Apply the 500-file cap before untracked-file inspection and count
  untracked additions with bounded in-process reads instead of one git
  process per file.
- Decode summary identity fields strictly (lossy batch drops malformed
  entries), route the diff pager by stable file path with a fail-closed
  missing state, single-pass prefix truncation, read summary-cache
  entries after the suspension point, and scrub RPC parameter names from
  user-facing error copy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Bound snapshot and inspection work, fail closed on remote provenance

Second review round: snapshot git commands stream through the bounded
reader with a 32 MiB ceiling, 30s wall deadline, cancellation checks, and
explicit truncation; untracked inspection gets a 64 MiB aggregate budget
with cancellation between files; the client clamps Show more progression
at 96,000 lines and builds the parsed document, row projection, and
gutter width together off the main actor as one immutable presentation;
intra-line emphasis is skipped for lines over 4,096 UTF-8 bytes before
any Character materialization; remote-provenance workspace paths never
reach local git (summary reports not a repository, content verbs return
not_a_repo); both process-lifetime caches purge expired entries globally
and hold at most 64 entries with LRU eviction.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Move directory policy into CmuxGit and bound zero-byte cache entries

The workspace-directory provenance policy is consumed by the macOS app
target, so it lives in CmuxGit's changes domain (CmuxMobileRPC is an
iOS-group package the Mac app cannot resolve; its tests move to
CmuxGitTests). The base-content cache adds a 256-entry LRU count bound so
zero-byte blobs, which are invisible to the byte budget, cannot grow the
entry map and temp-file population without limit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Scope refresh fanout, decouple pinch from rows, bound caches, pin revisions

Review round 4: workspace deltas schedule changes-summary refreshes only
for the delta's workspace IDs (group-only deltas skip entirely); pinch no
longer rebuilds the row projection (rows depend only on document,
expansion, and current lines; gutter width derives cheaply at render);
the sheet's parsed-presentation cache is a 7-entry LRU around the
selected page; file_diff/file_stat/file_fetch carry an additive stat
fingerprint so expansion fetches from a newer working tree are discarded
and the diff refreshed instead of splicing mixed revisions; Show more
now continues when the host cannot report a total, with loaded-only
progress copy (en+ja).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Harden content reads, cache keys, fingerprints, and refresh coalescing

Review round 5: expansion downloads enforce a cumulative 5 MiB cap and
chunk-count ceiling inside the transport loop; the diff's content
fingerprint stats the working file before and after git runs and returns
a never-matching unstable token when they differ; base blobs are keyed
and fetched by an immutable commit OID instead of the moving HEAD ref;
content reads walk the validated path component-by-component with
O_NOFOLLOW anchored at a repository-root descriptor so a post-validation
symlink swap cannot escape the repository; the summary-refresh debounce
accumulates a union of pending workspace IDs with a dominating
all-workspaces flag instead of dropping scopes on restart.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Recoverable cancellation, pinned transfers, safe inspection, guarded publishes

Review round 6: connection-transition CancellationErrors publish the
error state (silent stop only under real task cancellation); chunked
content transfers resolve scope, authorization, base OID, and base size
once and stay pinned via the authorized-path cache, which is now
revision-keyed so a moved base refreshes the snapshot; artifact
transfers verify every chunk's content fingerprint against the initial
stat; untracked inspection reuses the O_NOFOLLOW component-walk opener
and rejects symlinks and non-regular files with cancellation checks;
diff load, Show more, and expansion publishes are generation-guarded so
a superseded request can never overwrite a newer presentation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Single-flight summaries, bounded parsing, post-read fingerprint checks

Review round 7: the summary debounce is separate from the fetch, which
is single-flight with a trailing coalesced pass instead of being
cancelled by every workspace delta; expansion line materialization runs
on a nonisolated worker with a 200,000-line bound; snapshot output
parses incrementally keeping at most the 500-entry cap plus running
totals instead of materializing unbounded path collections; content
chunks fstat the descriptor again after reading and fail closed when
identity, size, mtime, or ctime moved.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Literal pathspecs, fail-closed fingerprints, serialized expansion

Review round 8: Git commands taking network-selected paths run with
literal pathspec semantics; a working file that changes across the diff
capture retries once then fails with the retryable error instead of
publishing content with an unstable token; once a fingerprint is
established every subsequent response must carry a matching token (nil
observed fails closed, all-legacy hosts keep working); cached-lines
expansion sets pending state and coalesces reveal intents into one
cancellable rebuild; skipped-fresh summary refreshes arm one trailing
fetch at expiry and an additive force param bypasses the host's TTL
cache; the full image viewer regains Copy Path; the UIKit workspace
table's height caching accounts for chip presence so interactive chips
never clip.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Deadline every git call, typed repo outcomes, leases, identity fingerprints

Review round 9 accepted findings: the plain runner overload gains the
same 30s deadline and cancellation termination as the bounded overloads;
unborn repositories diff against the empty tree so untracked files list
(git failures map to gitFailure, never notARepository); truncated
changed-file snapshots render a bounded-result footer; untracked files
past the scan budget are prefix-probed and classified binary when
unknown; chunked base transfers hold eviction leases on their cache
entries; fingerprints carry device, inode, and ctime so same-size
same-mtime replacement is detectable. Two round-9 findings rejected by
design and documented in place: the default-branch HEAD comparison
fallback, and delta+TTL-driven summary refresh (repo-watching is a
follow-up).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Blob-identity fingerprints, hard git deadlines, budget-honest leases

Review round 10: base-revision fingerprints derive from the immutable
commit and blob OIDs so cache eviction cannot change a transfer's
identity; the git deadline terminates the process group, unblocks pipe
readers, and escalates to SIGKILL after a grace period; the base cache
reserves projected bytes before materializing, rejects when no unleased
victim can satisfy the budget, and leases every returned URL through its
use; missing fingerprints fail closed (this protocol always emits them);
Show more tasks are retained and cancelled with generation invalidation
when a page disappears; read-capped untracked counts are marked partial
and flip the snapshot's truncated flag.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Scope legacy refreshes, pin diff base to OID, prompt exits, self-expiry

Review round 11: legacy workspace.updated reloads schedule TTL-respecting
summary refreshes instead of forced app-wide sweeps; the verified base
commit OID is the diff base for every operation (symbolic name kept only
for display); the git deadline path stops waiting out the SIGKILL grace
once the process group is gone; successful summary fetches arm the
trailing expiry so chips self-refresh after the TTL.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Group-liveness deadlines, honest TTLs, pruned state, bounded page memory

Review round 12: git deadline termination and escalation track
process-group liveness so descendants holding stdout are reaped; summary
TTLs stamp at batch completion with a floored trailing delay so slow
hosts cannot loop at zero delay; summary state prunes against the
current workspace set and consumes state-sync removals before rearming;
diff pages hold heavy state only in a selected-neighborhood window, with
other tabs mounting on selection; the workspace table's height cache
keys by digit-count buckets and chip mode instead of exact live totals.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Restore submodule pointers, cache snapshots, gate polling, unpin executors

Review round 14 (Claude engine): the origin/main merge had resolved the
ghostty and vendor/bonsplit gitlinks to older branch-side commits; both
are restored to main's pointers. File diffs and changed-file lists now
serve a 15-second LRU loaded-snapshot cache so pager mounts reuse one
repository walk (force bypasses it); the summary trailing refresh only
re-arms while workspace events are recent, so an idle connected phone
cannot hold the Mac in a perpetual 15-second git poll; blocking git
spawn/poll/reap loops run on a dedicated GCD queue bridged with
continuations instead of pinning the cooperative executor; the
developer-scratch live-repo probe test is removed; SwiftUI list rows
gate the chip tap closure like the UIKit path so chip-less rows keep
combined VoiceOver navigation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Move base-revision git and large decodes off blocking executors

Review round 15: the base-content cache takes an async materializer so
the actor suspends instead of blocking on git show (post-await collision
adopts the winning entry); the rev-parse and cat-file probes and the
materializer run through the dedicated blocking queue per the service's
own executor contract; multi-megabyte file-diff and content-chunk JSON
payloads decode in nonisolated async helpers so Show more and binary
previews never run their decode pass on the main thread.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Live cancellation on GCD git work, bounded hint keys, injected clock

Review round 16 nits: a thread-bound cancellation signal bridges Swift
task cancellation into the GCD-hosted git loops (Task.isCancelled reads
false there), so unmounted pages and dropped connections stop subprocess
reads and untracked scans early instead of riding out the wall deadline;
the hint-dismissal store keeps seen workspace IDs in one 256-entry FIFO
array key instead of unbounded per-workspace defaults keys; the summary
debounce and trailing-expiry sleeps use an injected Clock so scheduling
is test-drivable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Assert renamed-file diffs are paired, not add-only

The rename test accepted any non-empty diff, so a full-file addition
(the current behavior) passed. It now requires rename headers and no
content lines as additions for a pure git mv.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Pair renamed-file diffs by including the old path in the pathspec

git applies pathspec filtering before rename detection, so fileDiff's
new-path-only pathspec made -M unable to pair renames: the diff page
showed the whole file as added while the file list's paired numstat
showed the true +/- counts. Tracked diffs now pass both the validated
old and new paths after --, restoring similarity headers and an empty
hunk body for a pure git mv.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Assert CRLF diffs split, count, and truncate per line

Red test: the truncator must count each CRLF-terminated content line
and break at hunk boundaries inside CRLF diffs. Character-based
splitting treats \r\n as one grapheme, so today the whole CRLF hunk
body is one mega-line and these assertions fail.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Split diff lines on literal newlines so CRLF hunks survive truncation

Character-based split treats \r\n as one grapheme, so CRLF diff bodies
collapsed into a single mega-line: totals undercounted, interior hunk
headers went undetected, and an over-cap CRLF diff truncated to
metadata-only text that the phone rendered as an empty diff. The
truncator now splits with components(separatedBy: "\n"), matching the
iOS UnifiedDiffParser's handling of the same pitfall.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Keep content reads and diff truncation off the cooperative pool

fileStat's open+fstat, fileFetch's chunk read (up to 3 MiB), and
fileDiff's decode+hunk-split of up to ~13 MiB of git output ran on
Swift-concurrency cooperative threads; a repo on a network or external
volume could pin one for seconds per call. All three now route through
the same offCooperativePool seam as the service's git subprocess work.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Retire the trailing expander once a fetched file proves it empty

On added files and EOF-touching diffs the trailing "Expand hidden
lines" band was a permanently dead control: the tapped gap resolved to
nothing against the fetched file, and that path cleared pending state
without publishing the fetched lines, so the projection never learned
the line count, the band never disappeared, and every tap re-downloaded
the whole file. The nil-gap path now recomputes the presentation with
the fetched lines, which removes the band and caches the lines.

Covered at the projection level (band present without a line count,
gone with one); a true page-level red/green is not practical because
the page is @State-bound SwiftUI rather than an observable model.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Decode changed-file paths strictly so malformed entries are omitted

File.init decoded every field leniently, so an entry missing its path
became path "" instead of throwing: the batch loop's omission filter
never fired, the list showed a nonsense row whose diff request the host
rejects, and two such entries collided on the path-keyed SwiftUI
identity. The path now decodes strictly and rejects empty strings,
matching the sibling summaries decoder (strict identity, lenient
counts), so identity-less objects are dropped like non-object entries.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: cmux reload-cloud <cmux-reload-cloud@users.noreply.github.com>
* Fix main build: import CmuxWorkspaces in DockSplitStore+RestoredAgentLifecycle

PanelShellActivityState moved into the CmuxWorkspaces package, and
manaflow-ai#8690 merged a file that references it
with only a Foundation import, so current main fails to compile the macOS app
(cannot find type 'PanelShellActivityState' in scope). Sibling users of the
type such as Workspace+AgentLifecycle.swift already import CmuxWorkspaces.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Fix main build: explicit return for trailing switch expression

surfacePromptForResumeApproval ended with a bare switch statement whose
cases are contextless member expressions (.auto/.prompt/.manual), which
does not compile as a statement. Use 'return switch' so the cases get
their contextual type from the return type.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Fix main build: annotate closure result types in DockSplitStore+SessionSnapshot

The Xcode 26.5 toolchain on the reload builder cannot infer result types of
multi-statement closures that return nil-or-value, failing with 'generic
parameter could not be inferred' at the Dictionary(uniqueKeysWithValues:
compactMap) pair builder and the observation.flatMap guard. Annotate both
closures with their concrete result types.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…anaflow-ai#8868)

The feed row's swipe action was trailing and only offered Mark as Read on
unread rows. Move it to the leading edge and make it a read/unread toggle,
matching the workspace list rows: unread rows get Mark as Read
(envelope.open), read rows get Mark as Unread (envelope.badge).

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…Lifecycle (manaflow-ai#8873)

PanelShellActivityState lives in CmuxWorkspaces; the new extension file from
manaflow-ai#8690 only imported Foundation, so
every macOS app build from main fails (cannot find type in scope). Sibling
files using the same type already import CmuxWorkspaces.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* test: cover inline attachment render reuse

* fix: cache composer attachment thumbnails

* fix: bound attachment thumbnail work

* fix: cancel deleted attachment thumbnails

* test: cover attachment thumbnail cancellation races

* fix: preserve thumbnail work across attachment reuse

* fix: isolate inline attachment cell rendering

* fix: keep thumbnail task teardown actor-safe

* fix: support Xcode 16 thumbnail value types

* test: isolate attachment deletion undo history

* Fix compile errors after main sync

* test: cover direct attachment deletion undo

* fix: reconcile attachment rendering on undo changes
The inherited section still offered a cmux download badge, so someone
skimming the page could install upstream cmux and wonder where the Hebrew
support went. Labels that section as upstream and points back to the
Ivrix release.
Fixes two reordering bugs on RTL rows:

- A neutral that resolved LTR (the hyphen in "max-height", or a plain space
  between two Latin words) was assigned embedding level 0 instead of 2. Level 0
  sits below the paragraph level, so UAX manaflow-ai#9 L2 ended its level-1 run there and
  reordered the two halves separately: "max-height" rendered as
  "max <hebrew> -height".
- Number separators were never resolved as weak types, so "1.5" rendered as
  "5.1", "3,000" as "000,3", and "192.168.1.1" came out fully reversed.

Claude-Session: https://claude.ai/code/session_01TPRwsvsc1t88Wg5LaJ3rXb
Selecting at a prompt and typing replaces the selection; backspace and delete
remove it. Also fixes prompt click-to-move spending two arrow keys on a wide
character.

Claude-Session: https://claude.ai/code/session_01TPRwsvsc1t88Wg5LaJ3rXb
Selection editing was inert on fish, which advertises click_events rather than
cl. It now places the cursor with arrow keys instead of relying on the shell's
click support, which fish advertises but does not honor for injected events.

Claude-Session: https://claude.ai/code/session_01TPRwsvsc1t88Wg5LaJ3rXb
Tagged dev builds identified themselves as "cmux DEV <tag>", which is the
upstream's name, not this fork's.

Split the visible label out of APP_NAME into DISPLAY_NAME and set only
CFBundleName / CFBundleDisplayName from it. APP_NAME still names the .app
directory, because that has to keep matching what xcodebuild emits (via
BASE_APP_NAME) and what the rest of the dev tooling greps for: cleanup
scripts, the debug CLI, the UI tests and the docs all match on "cmux DEV".
Renaming the directory is a separate, wider change.

An explicit --name still wins for both, so a build is never called one thing
on disk and another in the Dock.
Shift+Left/Right selects by character, Shift+Option+arrow by word, with the
direction mapped by row so it reads correctly in Hebrew. Also fixes cell
backgrounds, selection highlight and decorations being painted at the logical
column instead of the visual one on RTL rows.

Claude-Session: https://claude.ai/code/session_01TPRwsvsc1t88Wg5LaJ3rXb
Mouse positions now invert the row's bidi order, so selecting, clicking and
link hit-testing land on the cell under the pointer in Hebrew.

Claude-Session: https://claude.ai/code/session_01TPRwsvsc1t88Wg5LaJ3rXb
Prompt selection editing, Shift+arrow keyboard selection, and the bidi fixes
that make both correct on Hebrew rows.

Also gives the app its own version. Ivrix 1.0.0 shipped carrying cmux's
0.64.20, so nothing could compare Ivrix releases to each other.

Claude-Session: https://claude.ai/code/session_01TPRwsvsc1t88Wg5LaJ3rXb
The sidebar button runs Sparkle against SUFeedURL, which named upstream cmux.
An Ivrix build asking upstream for updates is offered upstream's releases, and
because they are different applications, accepting one replaces Ivrix with
cmux and loses the Hebrew build. Ivrix 1.1.0 shipped in that state.

Three places had to change, and the second is the one that would have bitten
again later:

  - Resources/Info.plist SUFeedURL now names this repo.
  - UpdateFeedResolver's default fallback did too, and it is used whenever the
    plist value is missing or empty. Fixing only the plist would have left a
    silent path back to upstream.
  - build-ivrix.sh stamps the feed URL and the EdDSA public key into the built
    bundle, and refuses to build if the feed still points upstream or if the
    public key is missing or is upstream's. A wrong value here is worse than
    having no updater, so it fails the build rather than warning.

Adds scripts/ivrix-appcast.sh to generate and sign the appcast a release needs;
without that asset the feed URL 404s and no update is ever offered. The signing
key is read from the login Keychain by sign_update and never touches the repo.

Still needed before an Ivrix build can actually self-update: generate the
keypair once with Sparkle's generate_keys and rebuild with its public half.
macOS ships bash 3.2, where `set -u` treats an empty array expansion as
unbound, so the appcast script aborted before signing.
Reverts the gating. In a right-to-left session, going 'back' with the arrow
that points along the reading direction is the wanted behaviour, including
inside a full-screen application. The mismatch with an application's own
'press <-' hint is a display problem, not a key-handling one.

Claude-Session: https://claude.ai/code/session_01TPRwsvsc1t88Wg5LaJ3rXb
The RTL/LTR control in the titlebar was the only way to flip the terminal
print direction. Add a `toggleTextDirection` shortcut action, bound to
Ctrl+Cmd+H by default, plus a matching View menu item.

All four entrypoints (titlebar control, toolbar segmented control, menu
item, shortcut) now flip through one path,
`TerminalTextDirectionSettings.toggleDirection()`, instead of each site
computing the next direction itself.

Ctrl+Cmd+H is free of AppKit reservations and of every cmux default; Cmd+H
alone is Hide Application, so the Ctrl variant does not shadow it.

Per the shortcut policy the action is in `KeyboardShortcutSettings`,
editable in Settings > Keyboard Shortcuts, settable as
`shortcuts.bindings.toggleTextDirection` in `~/.config/cmux/cmux.json`, and
documented in the shortcut docs and the cmux-settings skill reference.
Localized across all 20 supported locales.
Hebrew layouts put HEBREW PUNCTUATION GERESH (U+05F3) and GERSHAYIM
(U+05F4) on the apostrophe and quote keys, so `echo "hi"` typed in Hebrew
arrives as `echo ״hi״` and the shell never sees a quote.

Rewrite those two scalars to ASCII `'` and `"` on the way into the
terminal, on both typed-text paths: the `insertText` accumulator and the
`textForKeyEvent` fallback. The rewrite is gated on `keyTextAccumulator`
being non-nil, so it only applies to live keystrokes — paste, dictation,
and programmatic NSTextInputClient callers keep their text verbatim.

`normalized` scans for the two scalars before reading UserDefaults, so
ordinary typing pays one pass over a one-scalar string and nothing else.

On by default, since shell quoting is the common case. Settings > Terminal
> ASCII Quotes on Hebrew Layout turns it off for typing Hebrew acronyms
such as צה״ל, which need the real gershayim; also settable as
`terminal.hebrewAsciiQuotes` in `~/.config/cmux/cmux.json`. Localized
across all 20 supported locales.

Also adds the direction-toggle coverage for the shared
`TerminalTextDirectionSettings.toggleDirection()` path added in the
previous commit.
The titlebar RTL/LTR button's hover tooltip named only the current
direction, so the new Ctrl+Cmd+H binding was undiscoverable from the
control it drives. Route it through `Action.tooltip(_:)`, the same helper
the sidebar, notifications, and focus-history buttons already use, so the
tooltip reads "Right-to-left (⌃⌘H)" and follows a rebind.
Selection editing at a prompt no longer refuses on the sole grounds that an
application owns the screen. It now requires what it actually needs: cells
marked as input by OSC 133 `B`.

No behavior change today. Claude Code, and every other full-screen
application I know of, emits no OSC 133, so their composers are still
copy-only. This makes Ivrix ready for the ones that mark, and turns the
open question into an upstream ask rather than a terminal-side guess about
where somebody else's input box begins.
Keeps the terminal-side half and the upstream request that unblocks it in
one place, so the next person to touch selection editing can see why it
stops at the composer of a full-screen application.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants