Repository navigation
zig/0.17: merge main (6 commits, to #490) - #503
Merged
Merged
Conversation
`app/crash/crash.zig`'s header still listed "forward a plugin dylib's panic message to the host" as a follow-up. That landed in #360 (released in sdk 0.2.23): the generated dylib root's `sdk.dylib.panic` forwards through `EditorAPI.panic` → `fizzyPanic` → `std.debug.panicExtra` → the root's `crash.panic`, so the report carries the plugin's message and frames. The comment now describes that path. Comment only; no code changes, so no SDK impact. **Verified:** read the path end to end (`sdk/src/dylib.zig:363`, `src/editor/Editor.zig:2406`, `src/Entry.zig:253`); `scripts/check-fmt.sh` clean. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
## What changes `sdk_version` goes from 0.2.26 to 0.2.27. Merging this tags `sdk-v0.2.27` and publishes its tarball. **Why now:** the agent plugin's `stroke` step (fizzyedit/agent#11, on agent `main`) uses `tape.Script.StrokePoint` from #431, which no released SDK has, so agent's new CI (fizzyedit/agent#13) can't pass against its pinned SDK until it repins to a release that carries it. ## SDK impact - [ ] None - [ ] Core-only or additive: reaches plugins at the next SDK release - [ ] Fingerprint moved: recorded in `sdk/src/version.zig`, `sdk_version` untouched, PR labelled `sdk` - [x] SDK release: bumps `sdk_version`, lists the `sdk` PRs since the last `sdk-v*` tag **`sdk` PRs since `sdk-v0.2.26`:** - **#434:** two optional document vtable members, `historyLine` (a document's undo history as a line of steps, with a `version`) and `historyGoTo` (undo or redo to a step), with `Plugin.HistoryLine`. An owner without them has no timeline; undo and redo are unchanged. The fingerprint moved, so the repin automation opens a repin PR in each store plugin. **Reaching plugins, no fingerprint move:** - **#431:** `tape`: a stroke carries the pointer in straight lines (`Script.StrokePoint`). - **#432:** `tape`: a caption follows where the pointer reaches, not a pen's path. - **#433:** `replay`: a tape's pointer gives way to a cursor the app draws itself. - **#427:** `core`: the bubble spinner keeps its bubbles near one size at any scale. Open `sdk`-labelled #437 (the Zig 0.17 switch) is a draft and not in this release. ## Verified - [x] macOS: - On this head, `zig build test-sdk-version`: 12/12 steps succeeded. `scripts/pack-sdk.sh` packs `fizzy-sdk-v0.2.27.tar.gz`. - fizzyedit/agent (main plus fizzyedit/agent#14) builds against this `sdk/` (`--fork`) for x86_64-windows, x86_64-linux-gnu and aarch64-macos, and `zig build test` passes. - [ ] Windows: - [ ] Linux: CI. - [ ] Web: ## Follow-ups - The repin automation opens `sdk: repin to fizzy SDK 0.2.27` PRs in the store plugins whose build needs it. agent repins too, and then fizzyedit/agent#13's CI can go green. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Part of fizzyedit/plans#5 (`plans/ZIG_0_17_PLAN.md`, step 0) ## What changes Nothing anyone sees. `@import("builtin").os`, `.cpu`, `.abi` and `.object_format` become `builtin.target.os` and so on: 235 sites in 51 files, a pure rename. Zig 0.17 deprecates the short forms and 0.18 removes them. 0.16 already has `builtin.target`, so this lands on `main` now and keeps the 0.17 switch smaller. The step's other item, the `@hasDecl` audit, needs no change. 0.17 makes `@hasDecl` see only `pub` declarations in every file, so a non-`pub` target would quietly read as absent. All 28 sites in fizzy look for a `pub` declaration. No store plugin or example declares `service_version` or a layout's `context()` in a way that would be missed, and `docs/PLUGINS.md` already shows `pub const service_version`. ## SDK impact - [x] None. `sdk/tape` and `sdk/replay` are touched by the rename; the fingerprint does not move (`zig build test-sdk-version` passes). - [ ] Core-only or additive: reaches plugins at the next SDK release - [ ] Fingerprint moved: recorded in `sdk/src/version.zig`, `sdk_version` untouched, PR labelled `sdk` - [ ] SDK release: bumps `sdk_version`, lists the `sdk` PRs since the last `sdk-v*` tag ## Verified - [x] macOS: `scripts/check-fmt.sh`, `zig build`, `zig build test`, `zig build test-integration` (53/53 steps, 522/522 tests), `zig build check-web`, `zig build test-sdk-version`, all on 0.16. - [ ] Windows: CI's cross-compile. - [ ] Linux: CI. - [x] Web: `zig build check-web`. ## Follow-ups None for this step. The rest of the 0.17 work goes on the `zig/0.17` integration branch (plans#5, "Where the work lives"). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
## What changes `scripts/check-examples.sh` failed on macOS: its `pack()` tars the tree with bsdtar, which writes an AppleDouble `._name` entry beside every file carrying extended attributes — every checked-out file has `com.apple.provenance`. The fizzy tarball then held `backend/patches/sdl/._0001-….patch` and friends, and the SDL patch step (`run exe patch`) failed with `NoSections`, failing the example-app studio/minimal/endless/dvui builds. CI runs on Linux with GNU tar, so it never saw this. `pack()` now gets the same treatment `scripts/pack-sdk.sh` already applies: under bsdtar, `COPYFILE_DISABLE=1` plus `--no-xattrs --no-mac-metadata`. The flags are passed only when `tar --version` says bsdtar, so GNU tar on CI runs exactly as before. ## SDK impact - [x] None - [ ] Core-only or additive: reaches plugins at the next SDK release - [ ] Fingerprint moved: recorded in `sdk/src/version.zig`, `sdk_version` untouched, PR labelled `sdk` - [ ] SDK release: bumps `sdk_version`, lists the `sdk` PRs since the last `sdk-v*` tag ## Verified - [x] macOS: before, `tar -cf -` of `backend/patches/sdl` held 10 `._` entries; with the flags, 0. `EXAMPLES_JOBS=2 scripts/check-examples.sh` prints `ok` for all eight builds (plugin, studio, minimal, studio-zon, endless, dvui, replay, dvui-sdl3), exit 0. - [ ] Windows: - [ ] Linux: unchanged path (GNU tar, no flags); CI's examples job covers it. - [ ] Web: ## Follow-ups None. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Part of fizzyedit/plans#13 (step 1). ## What changes An app built on fizzy chooses which of fizzy's plugins it links. `fizzy.plugins.{workbench,text,image,archive,markdown,devtools}` are entries in the list `fizzy.buildApp` takes, beside the app's own, and `fizzy.plugins.all` is fizzy's own set: ```zig try fizzy.buildApp(fizzy_dep, &.{ fizzy.plugins.workbench, fizzy.plugins.text, .{ .name = "graph", .module = graph_dep.module("plugin") }, }); ``` Fizzy's own entries have no module: `build/plugins.zig`'s `link` builds each one for every executable (native, packaged, web), wired to that executable's framework, and re-homes the app's modules the way the native and web builds each did on their own before. Fizzy's build passes `fizzy.plugins.all` through the same path, so the exe and the web build no longer keep separate copies of the list. The test build keeps its own list, because it tests fizzy itself. A plugin that isn't listed doesn't register, and fizzy's dylib for it (text, markdown, image) is neither built nor installed. Two plugins are always built, because fizzy's own code imports them as libraries. **workbench** must be listed: the editor still holds its state (`editor.workbench`, the dylib's `arg_c`), and a list without it fails the build with a message naming the plan. **markdown** is always compiled, because the store draws READMEs with it, but it only registers as a plugin when listed. **Transition:** fizzy's CI builds example-app's `main`, and example-app pins fizzy by commit, so neither repo can make the change first. Until example-app lists fizzy's plugins, a `buildApp` list that names none of them gets all of them first, as before. That fallback is three lines in `buildApp`, and step 2 removes it. fizzyedit/example-app#6 lists them explicitly (its minimal shape links only workbench and text), and it repins to this once merged. Also: - `Entry.zig` drops an unused `@import("text")`. - The `postInit` comment no longer says the bundled plugins don't ship as dylibs. They do, and it now says why. - The root `AGENTS.md` describes the list. Overlaps in files only with the Zig 0.17 drafts (#437, #438), which touch the whole build; this change touches no build step. ## SDK impact - [x] None ## Verified - [ ] macOS: `zig build`, `check-web`, `test`, `test-integration`, `test-sdk-version`, `check-fmt`. The four built-in dylibs are still installed to `zig-out/<target>/plugins/`. `scripts/check-examples.sh` passes against example-app with explicit lists (its minimal shape links only workbench and text), and against example-app's `main` through the fallback. - [ ] Windows: - [ ] Linux: CI - [ ] Web: `check-web` ## Follow-ups - Step 2 (plans#13): a link-only build, and removing the fallback. - workbench becomes optional once its state moves from the Editor into the plugin. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Part of fizzyedit/plans#8 · Needed by fizzyedit/agent#16 ## What changes `EditorAPI.OpenOptions` gains **`beside: bool = false`**: open the document beside the pane the person is in, leaving them that pane and their focus. It's what fizzy does for a claimed document (`showClaimed` → `Workbench.showBeside`), now available without a claim. - An open document goes beside at once (`openPath`, instead of focusing it). - One still loading carries the flag on its `Opening`, and goes beside when it lands (`Openings.land`). - `grouping` is ignored when `beside` is set. - **`Host.openBeside` / `endOpenBeside`**: while one is open, documents opened *or created* land beside the person. A sprite an agent creates (`pixi.create`) appears beside them at once, fitted to that pane, instead of opening over their document and then moving. The agent plugin opens it around each command (fizzyedit/agent#16). **Why:** an agent's commands claimed the document they edited (fizzyedit/agent#10), which showed it beside the person but also held back all their input to it for up to a quiet minute. Agents now work *with* the person (fizzyedit/pixi#31 makes a pixi edit wait for the person's open stroke or transform instead), so the hold goes (fizzyedit/agent#16), and this keeps the "shown beside you" part. `OpenOptions`' own comment anticipated it: the next answer to "where does it land" is a defaulted field. ## SDK impact - [ ] None - [ ] Core-only or additive - [x] Fingerprint moved: recorded in `sdk/src/version.zig` (`0xbd784c59f3146ea4`), `sdk_version` untouched, PR labelled `sdk`. Plugins see one new defaulted field on `OpenOptions`. - [ ] SDK release ## Verified - [x] macOS, sandbox with fizzyedit/agent#16, pixi and chat built `--fork` against this: - an agent's `pixi.draw` on a background tab moved that sprite into a pane beside the person's, while their pane and its active document stayed theirs - creating a sprite and drawing on it in one `fizzy_run` put it beside them, and a sprite created after it stayed in their pane (checked by capture) - `scripts/check-fmt.sh`, `zig build`, `zig build test`, `zig build test-sdk-version`, `zig build check-web`; `test-integration` left to CI - [ ] Windows: - [ ] Linux: - [ ] Web: ## Found on the way (not fixed here) - `fizzy_screen` leaves out some tabs: after a few documents had been opened beside, a pane drawing six tabs reported two `workbench.tab:` anchors. It misled a test here into "the document vanished". 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
A document that was never saved (pixi's `untitled-1`, made by `pixi.create` with no path) was lost when fizzy was killed or crashed. On 2026-10-15 a 64×64 drawing went this way: after `kill -9` and a relaunch, the log said `text: loadDocument failed: FileNotFound`, the pane showed "Couldn't open untitled-1", and `recovered/` was empty. ## Why The checkpoint (`Checkpoint`) and the restart session (`KeptDocuments`) keep each document's `captureDocumentState`, plus `documentBytes` for a dirty one. - pixi's state is its layers' pixels, not a whole document, so it can't fill an empty one. - The bytes were asked for under `untitled-1`, which names no format, so there were none. At the next launch the document was loaded empty for its state to fill, which pixi refuses. `openKept` then returned null, and `openPath` went on to open `untitled-1` from disk, routed by its missing extension to `text`. ## What changes - **Kept as a save would write it.** A dirty document that has never been saved is encoded under the name Save As offers. The new `Plugin.bytesPath(doc)` gives that name (`untitled.pixi`); for a document with a path it gives the path. The name is kept beside the bytes as `save_as` in `session.zon`. The field defaults to empty, so older sessions still read. - **Reopened through its owner, untitled.** A new optional hook, `loadUntitledDocument(title, bytes_path, bytes, out_doc)`, opens the bytes as an untitled document with its title (`untitled-1`), dirty. Its state is then restored over them. If the owner refuses that state, the document is still whole, since it opened from its own bytes. - **Never opened as a path.** If the owner has no such hook, or reopening fails, `openKept` now returns `lost` rather than null. The contents go to a recovery file named by the title plus the encoded extension (`<t>-untitled-1.pixi`, which opens in pixi), and `openPath` does not look for a file called `untitled-1`. - `text` implements the hook. ## Plugins - **pixi:** a 14-line `loadUntitledDocument` (`fromBytes(bytes_path, …)`, then the title). It lands in fizzyedit/pixi after this releases. Until then pixi's untitled sprites go to a correctly named recovery file instead of nowhere. - **agent:** the session recorder (`src/Recording.zig`) called `documentBytes(doc, path)` and so recorded nothing for untitled documents. That's the same root cause; it switches to `bytesPath`, which also names the tape's mounted file so it replays in its owner. Both plugin changes are ready locally and go up with the repin. ## SDK Labelled `sdk`. The fingerprint moves (new optional vtable field `loadUntitledDocument`); `sdk_version` is untouched. For plugin authors: - `Plugin.bytesPath` is the path to hand `documentBytes` when you need a document's bytes outside a save: a recording, or a copy you keep. - An owner whose untitled documents should come back from a crash implements `loadUntitledDocument`. ## Verified - **Integration test** (`tests/integration.zig`, beside the restart test). An untitled, dirty text document is kept with no state, so only its bytes are left. It comes back untitled, dirty and whole. With the hook disabled, the same test fails with `text: loadDocument failed: FileNotFound`, the error from the incident. - **macOS, sandbox profile of my own** (`--profile`, with pixi built from its branch against this SDK via `--fork`): 1. A tape ran `pixi.create` (64×64, untitled) and `pixi.draw` (a rect, a line, an ellipse). 2. After a checkpoint, `kill -9`, then a relaunch. 3. `untitled-1` came back in pixi, dirty. The next checkpoint again lists it as `dirty = true, save_as = "untitled-1.pixi"`. 4. Its exported pixels match the kept `.pixi`'s layer byte for byte: 240 rect, 64 line and 72 ellipse pixels. 5. No `text:` error, nothing written to `recovered/`. - `check-fmt`, `zig build`, `test`, `test-integration` (53/53 steps), `check-web`, `test-sdk-version`, `check-tests-reached`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Glass that keeps its frost until a witness changes (`BlurBackdrop.Pane.witness`, pixi's canvas buttons through `core.dialogs.glassBubbles`) now reads again while the host draws a region's swap over the view under it. ## What was wrong Switching a pane's tab from grad.png (a blue-to-pink gradient, image plugin) to a `.pixi` sprite left the sprite's glass buttons tinted with the gradient for good. The readback was not reading an old frame: a frost reads the frame target in its deferred job, after everything beneath it has drawn this frame. What was beneath it was the host's swap. Every region swap (`Layout.drawSwapped` → `core.anim.transition`) draws the outgoing view's photograph over the incoming view and fades it out over ~0.4 s. Pixi's buttons are floating subwindows, so their frost reads the frame after that overlay has drawn. The witness (art, canvas rect, button rect, blur style) describes only the sprite, and it held still while the photograph faded, so the frost kept whatever frame of the fade it first read. In the repro log, the last capture came at fade alpha 0.95, and nothing re-read after that. ## The fix The witness can't describe this: the caller doesn't know what the host draws over its view, and a scene signature in every witness would put the host's state in every plugin's hash. Re-reading once on the frame after the frost forms doesn't fix it either, because the fade lasts many frames. Instead, the host says when it draws something no witness covers: - `BlurBackdrop.underChanged(rect)`: something no witness describes was drawn over `rect` this frame. It is kept in the shared dvui window (as `FrameTarget.want` is), so a plugin image's glass sees what the host drew. - `CrossFade.draw` marks its snapshots' rects every frame it draws one. - A kept frost whose capture rect meets a mark from this frame or the last one re-reads, in its deferred job (`FrostJob.draw`). That job runs after the overlay has reached the target. The extra frame covers the first frame after the fade, when the overlay has gone. Cost: one data-store lookup per kept frost per frame, and nothing is stored while no swap runs. During a swap, the kept frosts under it re-read every frame, as an unkept frost always does. The next thing that draws over a view in a way its witness can't see (a carried view's photo, say) calls `underChanged` the same way. ## SDK No fingerprint move. Pixi's glass gets the fix once it is rebuilt against this `core` (next SDK release + repin). Until then, the host still marks and pixi just doesn't read the marks. ## Verified macOS, sandbox profile, pixi `main` built with `--fork` against this tree. A live tape opens sprite.pixi and grad.png in one pane, shows the sprite, then shows the gradient for ~1.5 s, then switches back to the sprite. The frame was read back from fizzy's frame target after the tape (the display was asleep): - before: the dropper bubble stays blue from the gradient after the switch - after: both bubbles show the sprite's canvas `zig build`, `zig build test` (new unit tests for the marks' this-frame / next-frame / expired lifetime and overflow), `zig build check-tests-reached`, `scripts/check-fmt.sh`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…ts it back (#444) ## What changes **Fit mode for `CanvasWidget`** (pixi's sprite, the image viewer). - A canvas starts fitted to its pane. While it is fitted, a pane that *settles* at another size refits and recentres it: a document moved to a narrower pane beside, a window resize, the explorer opening. - The moment the person moves the view (a wheel over the canvas, a pinch, a trackpad zoom, a drag-pan, a touch pan), it stops being fitted. Nothing refits it after that, so their place is kept, as before. - New content (a different size) starts fitted again. - `fitToView()` fits it again and keeps it fitted. Before this, a canvas settled once and never refit. A sprite an agent created in the person's pane and then moved beside them kept a view fitted to the wide pane: off centre and cropped in the narrow one (reported with a screenshot). **`fizzy.fitView`:** "Fit to View", Cmd-0, in the View menu. It routes to the active document's owner as `<owner>.fitView`, the way Undo routes, through a new `fitView` document verb. The image viewer registers `image.fitView`. pixi's is fizzyedit/pixi (linked below). Detecting "the person moved it" happens at the input sites, not by comparing the viewport. The canvas's own pan-slack bookkeeping moves the scroll position every frame as layout, so a before/after comparison would misfire. **Fit to View is animated.** It moves the view eased in and out (`inOutCubic`, 450 ms at the app's motion speed) and never past the fit. The app's `settle` spring arrived early and swung past, which on a view read as a lunge. The move is a zoom about the one point on screen it leaves in place. Every point of the content moves toward or away from it by one shared factor, so x and y arrive together. Moving the middle's data point in a straight line had each axis's edges move at different rates. A move with no zoom slides. Where the view starts comes from the canvas's own state (origin + scroll position + half the box, over the scale), the inverse of where the fit places it. So a view already fitted is recognized, and Fit to View does nothing. Read off last frame's screen rects instead, the start was off by a zoomed view's scroll bar. **While fitted, the content follows the pane live.** A pane changing size refits on every frame it changes, so dragging a split or the explorer closing zooms it smoothly. Before, it waited for the pane to hold still for three frames and restored a snapshot of the old fit in the meantime, then caught up in one jump. That waiting and snapshotting still guard a view the person has placed. A fitted view is worked out afresh each frame, so there is no drift to guard against. The person's pan or zoom ends a move where it is. With motion off, a fit is the jump it was. ## SDK impact - [ ] None - [x] Core-only or additive: reaches plugins at the next SDK release (core's `CanvasWidget`; the fingerprint is unmoved). pixi's command uses it through an `@hasDecl` guard, so pixi builds on the released SDK meanwhile. - [ ] Fingerprint moved - [ ] SDK release ## Verified - [x] macOS, a profile of my own, with this change merged locally with #442 and pixi built `--fork`: - an agent created a 64×64 sprite and drew on it; it landed beside the person's sprite fitted and centred - after `pixi.view {zoom: 20}` on it (no longer fitted), toggling the explorer twice resized both panes: the zoomed sprite kept its zoom, and the untouched one stayed fitted and centred - Cmd-0 on the zoomed sprite fitted and centred it again - `scripts/check-fmt.sh`, `zig build`, `zig build test`, `zig build test-sdk-version`, `zig build check-web`; `test-integration` left to CI - [ ] Windows: - [ ] Linux: - [ ] Web: Animated fit and live refit: macOS, sandbox profile, measured with a temporary per-frame log of the sprite's screen rect (removed): - **A fit from 12x on the bottom left:** each axis's share of its distance per frame matched (0.24/0.24, 0.49/0.49, 0.83/0.83 … 1.00/1.00). No frame passed the fit, and the first frame didn't jump. - **A fit on a fitted view:** nothing moved. - **A fit from 24x on the top left, then from the bottom right:** each starts from where the view was. - **Closing the explorer with the sprite fitted:** the sprite re-centred on every frame of the pane's 175 ms widening (1193 → 933 px). It didn't jump at the end. ## Follow-ups - A Fit to View glass bubble among pixi's canvas bubbles, and the corner controls as glass bubbles (asked for; next). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Stacked on #444 (Fit to View); review that first. This PR's own diff is the top commit. ## What changes The round buttons that float over the app are glass bubbles now, the same glass as pixi's canvas controls and the demo player's transport: - **The places' corner button (Arrange)** is a 36 pt glass bubble with lucide's `layout-grid`, in place of the bordered disc with entypo's grid. Glass has no opacity, so where the disc faded, the bubble grows in from its centre, with its shadow. - **The explorer's peek collapse button** is a bubble, in place of a filled disc with a box shadow. - **The image viewer has Fit to View** as a bubble at the viewport's top left, where pixi keeps its canvas controls. It runs `fitToView` from #444. `core.widgets.glass_button` is pixi's `glass_button.zig`, moved into core so the shell and every plugin can draw it: `options`, `background`, `wash`, `witness` (a signature of what is under the glass, so the frost is read again only when that changes), and `bubble` (a viewport bubble in one call: floating, clipped to its screen, icon, tooltip; true when clicked). A toggled-on button stays glass: its icon takes the highlight (`iconColor`), where pixi's covered the glass with a highlight-filled disc. Pixi keeps its own copy until it repins onto an SDK carrying this. The image plugin now depends on `icons`, as text does: `build/plugins.zig`'s `link` passes it to the static module and `build/exe.zig` to the dylib. ## Pitfalls hit - A dvui button has a 4 pt margin by default, so a 36 pt `.rect` drew a 28 pt bubble with an icon padded nearly away. The corner button sets `.margin = .{}`. - A floating widget made inside a `CanvasWidget` takes the canvas's zoom. The Fit bubble is drawn after the canvas closes, from the viewport rect taken while it was open. ## SDK No fingerprint move. `core/` is new for plugins, and it reaches them with the next SDK release. ## Verified macOS, a sandbox profile (`--profile`), with captures through the agent plugin: - the corner bubble at 36 pt with its icon, lit under the pointer; - the image viewer's Fit bubble at the viewport's top left. The explorer's collapse button shows only while the explorer is peeking (a narrow window). It compiles but I didn't see it on screen. Gates: `check-fmt`, `zig build`, `test`, `test-integration`, `check-web`, `test-sdk-version`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
#449) Part of fizzyedit/plans#16 (step 1). ## What changes The file tree keeps what is the tree's own, and stops being reached into through the SDK. Three `Host`/`EditorAPI` entries that existed only for the workbench go. - **Expanded folders are the tree's.** `Explorer.open_branches` moves onto the workbench's `Workbench` instance (`open_branches`, `setBranchOpen`), and `explorerBranchIsOpen`/`setExplorerBranchOpen` go. The instance is shared by both copies of the plugin (static and dylib), which is why the state lives there and not in a module global. Fizzy's demo snapshots read and restore it on `editor.workbench`; plans#16 step 2 moves that behind something the workbench offers. - **A new file is revealed through the `files` service.** `setExplorerNewFilePath` goes. The `files` service (now `service_version = 2`) gains `reveal(path)`: "show this just-made path as a new file". Fizzy's implementation drops the listing cache, because the caller usually wrote the file itself, and emits `fizzy.files.reveal` (declared, `Path` payload). The workbench subscribes and queues the reveal, so the row opens selected, its name in inline rename, with the closing dialog aimed at it, as before. Any other file view could do the same. The workbench's own New Folder sets its pending path directly. - **The tree's stash id is made in its own draw.** `file_tree_data_id` was set and read within one draw, so fizzy clearing it whenever Files was hidden (`resetFileTreeWhenFilesHidden` → `clearFileTreeDataId`) did nothing. It is a local now, and both functions go. Event delivery is queued until the next frame, so a reveal lands one frame later than the direct write did. The tree already waits for the row to be laid out, so nothing visible changes. **Store plugins:** pixi (`src/dialogs/NewFile.zig`) and atlas (`plugin.zig`, New Note) called `setExplorerNewFilePath`; both now call `files.reveal`. Those are local jj changes in their repos, built against this tree's SDK (`zig build --fork=<this>/sdk`), to push with the repin to the release that carries this. No other store plugin, example-app or example-plugin uses the removed entries or the `files` service. **Overlap:** #446 also touches `src/editor/Explorer.zig` (the collapse button). This change only removes `open_branches` and the hidden-tree check there. ## SDK impact - [x] Fingerprint moved: recorded in `sdk/src/version.zig`, `sdk_version` untouched, PR labelled `sdk` For plugin authors: `Host.setExplorerNewFilePath(path)` → `host.getServiceTyped(sdk.services.files.Api)` then `.reveal(path)`. `explorerBranchIsOpen`/`setExplorerBranchOpen` are gone, with no replacement: the tree's open folders are the workbench's own state. A plugin built against `files` service version 1 gets null from `getServiceTyped` on this host (versions are refused, not cast), so it degrades rather than misbehaving. ## Verified - [x] macOS: - **Gates:** `zig build`, `test` (91/91 steps), `test-integration` (53/53 steps), `check-tests-reached`, `test-sdk-version`, `check-web`, `check-fmt`. - **New integration check** (in the whole-editor test): a file written outside the service and passed to `files.reveal` is heard by the tree, then taken once its row is laid out. Pointing the workbench's subscription at another event name makes it fail at `expect(heard)`, so it tests the round trip. - **Plugins:** pixi and atlas build against this SDK. - [ ] Windows: - [ ] Linux: CI - [x] Web: `check-web` Not looked at by eye: the dialog flying into the revealed row. The test covers the tree taking the path, which is the step that re-aims the dialog. ## Follow-ups - plans#16 step 2: the Explorer's file parts move into the workbench, Explorer becomes `SidebarPane`, and demos reach the tree's state through the workbench. - Push pixi's and atlas's changes with the repin PRs after the next SDK release. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…451) No plan issue: a bug report from pixi's layer list (2026-10-15). Dragging a layer row out of the list kept something animating for as long as it was held, and the carried row was not the glass a tab or a file is when it is carried. ## What changes **The loop.** The slot a lifted tree row leaves behind kept the row's width from the moment it was lifted. While a row is dragged, pixi's list adds a drop row below the last one. That makes the list taller, its vertical scroll bar comes in and narrows the list, and the slot overflows it sideways. The horizontal bar that follows changes the list's height. The layers/palettes split, which fits that height with a slide, slides, the vertical bar goes, and round again. The slot now takes the list's width where the row did (`expand` horizontal) and keeps only the row's height. **The glass.** `TreeWidget`'s floating row always drew the app's frost (`dialogs.carriedGlass`). Every other carried thing goes through `ViewDrag`, which on macOS 26 is the OS's Liquid Glass in fizzy's carry overlay. The new `dialogs.carriedCard` gives a carried thing drawn in a floating widget of its own the same treatment: - Where the OS offers its glass outside a view drag (`native_glass.offered`, as pixi's dropper magnifier already uses), the card is declared to it and the layer goes over the glass (`screens.markCarried`). - Elsewhere it falls back to `carriedGlass`, as before. pixi draws nothing of its own for the carried layer; the row it carries is core's. So the fix reaches pixi, with no change in pixi, when the next SDK release repins it. ## SDK impact - [ ] None - [x] Core-only or additive: reaches plugins at the next SDK release (the fingerprint does not move; `test-sdk-version` passes) - [ ] Fingerprint moved: recorded in `sdk/src/version.zig`, `sdk_version` untouched, PR labelled `sdk` - [ ] SDK release: bumps `sdk_version`, lists the `sdk` PRs since the last `sdk-v*` tag ## Verified - [x] macOS (26, Debug): a sandbox profile running this build, with pixi built `--fork` against it. A sprite with three layers (`pixi.create`, `pixi.addLayer` ×2); a layer row dragged out over the canvas and held still for 3 s, measured with fizzy's profiler (`fizzy_profile` through the agent plugin): - before: the docking split's fit slide (`DockingWidget.zig:542`) asked for every one of 202 frames, the list's scroll-drag (`ScrollContainerWidget.zig:418`) and overscroll bounce (`:209`) about 50 each, plus ~100 min-size refreshes. Captures 0.4 s apart showed the scroll bars coming and going and the rows shifting sideways. - after: the only frames left come from the test driver holding the pointer (`LiveDriver`), and the captures are byte-identical. - glass: a screen capture mid-drag shows the carried row as the OS's Liquid Glass capsule, refracting what is under it, with the row's content over it. Released, the row drops back into the list. - `check-fmt`, `zig build test`, `test-integration` (53/53 steps), `check-web`, `test-sdk-version`. - [ ] Windows: not run. There is no `native_glass.offered` there, so `carriedCard` is `carriedGlass` as before. - [ ] Linux: not run (CI). Same as Windows. - [ ] Web: `check-web` builds; not run in a browser. Same path as Windows. ## Follow-ups - `ReorderWidget`'s floating item still carries a plain box shadow rather than a carried card. It is a candidate for `carriedCard` too; left out to keep this change to the tree. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
#454) A chat streaming a reply in (fizzyedit/chat) jittered: the conversation dropped by a line and came back, many times a turn. The cause is in the markdown service's inline render (`drawInline`, which a chat bubble uses). `renderTopLevel` pins a block's emitted height to its height-table entry. That keeps a virtualized preview still while blocks come back from spacers. `drawInline` passes no viewport, so nothing is virtualized and every block is laid out every frame, but the pin still applied. A streamed reply changes its text on every chunk, and the chat's type-in changes it on every frame. Each change re-parses, which clears the table: 1. Frame A: the edited block has no entry and is laid out at its real height. If its text layout is new (a paragraph that just started), the measurement is filed `.deferred`, which keeps the estimate. 2. Frame B: the block is pinnable (`.deferred`), so the pin emits the estimate, which is a line or so short. 3. Frame C: real again. The caller sees H, then H minus a line, then H. With the chat following the bottom, the whole conversation moved down by that line and back. The fix gates both pins on `virtualize`, so a render with no viewport emits what it lays out. The preview pane, which always has a viewport, is unchanged. How it was seen: fizzy from main (Debug, macOS) in a sandbox profile, with this markdown dylib in place of the bundled one. A temporary driver in the chat plugin streamed 8 scripted turns, in 4 to 40 byte chunks every 20 to 140 ms. A per-frame log recorded each message row's rect, min size and the scroll position. | over 20 s of streaming | before | after | |---|---|---| | frames the conversation moved back down | 15 | 0 | | content-height changes that were shrinks | 22 of 78 | 0 of 36 | | frames a message's row was a frame behind its own size | 75 | 35 (only growth, one frame late, as dvui lays out) | Example, one reply's row height across three frames with the same text: 98.1, then 80.0, then 98.1 before; 98.1 throughout after. Test: the headless integration test's inline-render check now streams a reply in 9 bytes a frame and fails if the height ever drops. Without the fix it fails ("shrank from 36.4 to 19.6 at byte 18"); with it, `zig build test-integration` passes 53/53 steps, and so do `zig build test` and `scripts/check-fmt.sh`. `check-web`, `test-sdk-version` and the full app build were left to CI: no SDK or build file changes. No plan step; a fix found while measuring the chat. The chat's own resize fixes are fizzyedit/chat#14 and don't depend on this. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…405) Compiles the SPIR-V and DXIL that fizzy's native backend embeds (Vulkan, D3D12) from their HLSL in CI, and fails when what is committed is not what the HLSL compiles to, uploading the fresh files as an artifact to commit. **Why.** Nothing noticed when an HLSL changed and its compiled files did not: Windows and Linux quietly drew the previous glass after #227 (fixed by hand in #242), and again since #373, whose shader changes are in the HLSL but not in the DXIL or SPIR-V. Its first run here found worse: main's glass HLSL has not compiled since #373 (`float3(x)` from one scalar is Metal and GLSL, not HLSL). Fixed here (`(float3)x`). **The toolchain** is SDL_shadercross at `1ff05bec`, with the DirectXShaderCompiler release it pins itself (Microsoft's prebuilt v1.9.2602, by SHA-256), built as shadercross's own CI builds it: only SPIRV-Cross and shadercross, a few minutes, cached by the pin. With it the backend's default shaders, unchanged since they were compiled, reproduce byte for byte, SPIR-V and DXIL: the committed files were made with this compiler. (An earlier run built shadercross's vendored DXC from source instead, half an hour: its SPIR-V matched, its DXIL differed in DXC's version string.) **The glass** SPIR-V and DXIL are compiled again from #373's HLSL (with the fix above). The DXIL's interface (input and output signatures, pipeline state, feature flags) is identical to the old one's, so the backend's pipeline for it is unchanged. **First step of making the glass one source.** Next, in its own PR: the glass written once, in HLSL, its Metal and WebGL GLSL generated from it by SPIRV-Cross and checked by this same job, so the forms cannot drift apart as they did here. **Not seen on Windows or Linux yet**: the new glass on D3D12 and Vulkan wants a look on those machines. `zig build` and `zig build check-web` pass on macOS. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Part of fizzyedit/plans#13 (step 2). Builds on #441. ## What changes An app built on fizzy can run with linked plugins only: ```zig const fizzy_dep = b.dependency("fizzy", .{ …, .@"defer-app" = true, .@"dynamic-plugins" = false }); ``` (or `zig build -Ddynamic-plugins=false`). In that build: - **No plugin dylib is built or installed.** fizzy's four bundled ones are skipped, as are the packaged copies. - **Nothing loads at runtime.** One comptime constant, `App.loads_plugins`, turns off: - the bundled-dylib probe in `postInit` - the user plugins directory scan (`loadUserPlugins`) - loading a plugin by id (the store's and settings' enable path) - a web page's `?plugin=` request - **No store.** `PluginStore.register` still sets the `PluginManager` (settings name plugins through it), then returns before the catalog fetch and the store's view. `tick` returns at once. Every gate is comptime, as wasm's are, so a dynamic build checks nothing at runtime. Most other dynamic paths go quiet on their own when nothing is `dlopen`ed: the rebuilt-plugin watcher walks only the loaded libraries. CI compiles the link-only build on Linux (`-Ddynamic-plugins=false -Dno-emit`, about 10 s locally), because its gates are comptime and the default build never compiles that branch. ## SDK impact - [x] None ## Verified - [x] macOS: - **Build:** `zig build -Ddynamic-plugins=false` installs only the executable, with no `plugins/` directory. - **Runtime:** a `text.dylib` planted beside the executable and in the profile's user plugins directory is not mapped into the link-only process (`vmmap`). The default build, run with the same profile, maps all four of fizzy's dylibs. - **Screenshots:** the link-only window has no store icon in the rail, and the default build has it above the settings cog. - **Gates on the default build:** `zig build`, `test` (91/91 steps), `test-integration` (53/53 steps), `check-tests-reached`, `test-sdk-version`, `check-fmt`. - **Web:** `check-web` with and without the flag. - [ ] Windows: - [ ] Linux: CI (including the new link-only compile) - [x] Web: compiles link-only; not run in a browser Not exercised: opening Settings in the link-only build. Its plugin rows read through the `PluginManager`, which is set as before. ## Follow-ups - **Removing the `buildApp` fallback** (a list naming none of fizzy's plugins gets all of them) waits until fizzyedit/example-app#6 is on example-app's `main`, because fizzy's CI builds that `main`. It's a small PR after the repin. - **example-app: a link-only build** in its CI and in `check-examples.sh`, after this merges, for the same ordering reason. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Part of `plans/RECORDING_PLAN.md` (fizzyedit/plans#17), step 1: the tape format. ## What changes A tape can carry **a document's own history**, not just input: - **`step` op** (`Tape.Step{ file, effect, motion }`): applies one change's effect, in its owner's encoding, to a file of the keyframe before it, shown being made over the op's `ms`. Effects replay exactly at any window size and in any layout; input only replays in the window it was recorded in. Steps land with **no frame between them**, so a seek across an hour of drawing is one sequencer call. - **Files carry bytes and an owner**: `File.text` may be a binary document's bytes as on disk (ZON escapes them; round-trip tested), and `File.owner` / `File.format` say which plugin applies its steps and in which of its encodings. - **`Check.steps`**: the app says whose steps, in which format, it applies. Without it, a tape with steps is refused (`error.StepsUnsupported`). A misspelled chord shows when pressed; a step nobody applied would play a document that never changes and look fine. - **`Stage.step`** (optional): the replay player hands each step to the stage, with `ms = 0` while a seek or catch-up runs. **Fizzy's stage does not apply steps yet**: its `Input.check` has no `steps`, so fizzy refuses such tapes for now. That lands with the tracks (plan steps 3–6). **Binary form version 3:** - Steps; files' owner and format; a **blob table** for effects and motion. Not interned: effects almost never repeat, and hashing every byte to find out costs more than it saves. - An optional **deflated** body (`binary.Options{ .deflate = true }`): the inflated length, then one zlib stream. - Versions 1 and 2 still read. The v2 test uses a real file written by `main`'s codec (`sdk/tape/testdata/everything-v2.tape`), not a patched version number, since v3's layout differs. - **std 0.16's inflate has two defects** a shared file must not reach. It asserts past the end of its input on a stream cut short (`tossBitsShort` adds consumed bits where it should subtract them), and it reads the zlib checksum but never compares it. So the reader feeds it zeros without end past the stream, caps the output at the header's length (+1, to catch a stream that runs on), and checks Adler-32 itself. Worth re-checking at the 0.17 move. **Moved from the plan's step 1:** - Keyframe "documents as state" became file bytes plus an owner. A shared tape must create its document from nothing, and `captureDocumentState` only restores in place. - The idle-gap rule is deferred to the step that plays tracks. Nothing plays steps until then, and it is the player's rule, not the format's. **API:** `binary.write` / `binary.encode` take an `Options` argument (call sites updated; nothing outside this repo calls them, checked in example-app, example-plugin, agent and pixi). Measured, `zig build bench-tape -Doptimize=ReleaseFast`, synthetic strokes shaped as pixi's effect will be (pixi's real encoding is plan step 4): | Steps | Plain | Plain load | Deflated | Deflated write | Deflated load | |---:|---:|---:|---:|---:|---:| | 2,000 (≈ 76 min of drawing) | 275 KB | 0.04 ms | 100 KB | 8.7 ms | 0.86 ms | | 20,000 | 2.7 MB | 0.25 ms | 873 KB | 87 ms | 7.9 ms | | 100,000 | 13.3 MB | 1.5 ms | 4.2 MB | 432 ms | 40 ms | ## SDK impact - [ ] None - [x] Core-only or additive: reaches plugins at the next SDK release - [ ] Fingerprint moved: recorded in `sdk/src/version.zig`, `sdk_version` untouched, PR labelled `sdk` - [ ] SDK release: bumps `sdk_version`, lists the `sdk` PRs since the last `sdk-v*` tag The fingerprint doesn't move (`test-sdk-version` passes): tapes cross the plugin boundary as bytes. The `tape` and `replay` libraries ship in the SDK tarball, so a plugin that calls `tape.binary.encode` gets the new `Options` argument at the next release (none does today). ## Verified - [x] macOS: - `scripts/check-fmt.sh`, `zig build`, `zig build check-web`, `zig build test-sdk-version` and `zig build check-tests-reached` all pass. - `zig build test`: 557/558 (1 skipped). `zig build test-integration`: 53/53 steps, 525/525 tests. - New tape tests: steps' validation and ZON round-trip, the sequencer landing steps together, binary v3 round-trip plain and deflated, v1/v2 reading, truncation at every length and 4,000 random flips per form. A one-off 100,000 flips per form under ReleaseSafe passed. - [ ] Windows: not run (std-only format code). - [ ] Linux: CI. - [ ] Web: `check-web` builds. Nothing plays steps there yet. ## Follow-ups - Plan step 2: `encodeStep` / `applyStep` on the document vtable, text's implementation. - The idle-gap playback rule, with the step that plays tracks. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
**Stacked on #405** (its commits show here until it merges). The glass written once, in HLSL; its Metal and WebGL GLSL generated from it and checked by #405's job, instead of three programs kept in step by hand. Keeping them in step is where the shader bugs came from: #373's `float3(x)` was valid Metal and GLSL and broke the HLSL, and the compiled forms went stale twice. ## What changes - **`scripts/compile-shaders.sh`**: every `X.hlsl` with compiled forms committed beside it, to each of them. SPIR-V, DXIL and Metal by shadercross (the Metal SDL_GPU takes, as the backend's own default shaders already shipped). GLSL ES 3.00 for WebGL2 by spirv-cross from the SPIR-V, with NaN checks relaxed (HLSL's `min`/`max` otherwise come out wrapped in `isnan` tests at every pixel), float precision high, and the names the web binds (`vColor`, `vTextureCoord`, `uSampler`, `uTex1`, `uData`). The workflow runs it and compares every form. - **The glass loads the generated Metal and GLSL** (`LiquidField`); the hand-written `liquid_glass.metal` and `liquid_glass.glsl` are gone. The HLSL is the one source and carries the explanations the GLSL had. - **The web compiles a complete generated source as it is** (`web/web.js`, a `#version` line), on WebGL2 only: on WebGL1 the program fails and the glass falls back to its meshes (generated loops are not WebGL1's constant-bounded ones). - `program_example`'s committed Metal, hand-written to show the interface, is now generated like the rest (same interface). ## Verified - **CI's compile check**: the backend's default shaders reproduce byte for byte in all three of SPIR-V, DXIL and Metal with this toolchain. - **macOS**: the same in-app About dialog captured through the agent with the hand-written Metal (#405's build) and the generated one (this build): plain glass differs by a mean of 0.18 of 255, at most 2 (the dither); elsewhere only edges, where the dialog's contents sat a fraction of a pixel apart between the two captures. - **Web (Chromium, WebGL2)**: the generated GLSL compiles and links against the web's own vertex shader, and `uData`, `uSampler`, `uTex1` and `uMatrix` all resolve; this build's menus draw their glass through it, no program failed, and it looks as main's did. - `zig build`, `zig build test` (91/91 steps), `zig build test-integration` (53/53 steps), `zig build check-web`. - Not seen: Windows (D3D12) and Linux (Vulkan), as for #405; Firefox and Safari on the web. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Fizzy returns to the MIT license it had from 2022 until May 2026, and writes down what that promises. **Do not merge until Aryan-982 agrees to relicense #327** (4 lines in `build/app.zig` and `tests/bench/bench_replay.zig`, the only outside contribution made under GPL-3.0). `LICENSING.md`'s History section says that consent was given. ## Why GPL-3.0 worked against what Fizzy is: plugins are `dlopen`'d into the editor's address space, which makes a closed or paid plugin legally murky, and every app built with `fizzy.buildApp` would have to be GPL. The goal is the opposite: the editor and framework free forever, with plugins and applications free to be commercial. ## What changes - `LICENSE`: MIT, `Copyright (c) 2022-2026 Colton Franklin and Fizzy contributors`. - `LICENSING.md`: every release stays MIT; Fizzy itself never goes paid, open-core or "community edition"; the SDK never needs a key, account or payment to load a plugin; plugins and apps may be closed and paid; the store may list paid plugins; contributions are inbound = outbound, no CLA. - `TRADEMARKS.md`: what MIT doesn't cover. "a plugin for Fizzy" and unmodified redistribution are fine without asking. A modified build needs its own name and icon. - `CONTRIBUTING.md`: a short "Licensing contributions" section, plus a row in "Where knowledge lives". - `readme.md`: a License section. - `scripts/pack-sdk.sh`: the SDK tarball now includes `LICENSE` and lists it in `.paths`, because plugins vendor the SDK's source and MIT asks that copies keep the notice. This takes effect at the next SDK release and doesn't touch released tarballs. Sole trademark holder for now: Colton Franklin. Moving the trademark to a fiscal host or foundation later is the natural next step if the project wants that. ## How it was verified - `scripts/pack-sdk.sh` produces a tarball containing `LICENSE`, with `"core"` and `"LICENSE"` in `.paths`. - `zig fetch` on the tarball hashes it as the `fizzy-0.2.27` package. - No Zig source changed. ## SDK No boundary change; the fingerprint and `sdk_version` are untouched. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Part of `plans/RECORDING_PLAN.md` (fizzyedit/plans#17), step 2: owners' steps, with text's implementation. ## What changes A document's owner can report each change to its history as it happens, and apply recorded ones. - **`sdk.Step`**: `{ kind, id, effect, motion, ms }`. The kinds are: - `added`: a new history entry. - `joined`: more of the entry on top, such as a run of typing. Only the new part goes in `effect`. - `undone`: an entry taken back. Its effect is the entry's reverse. - `redone`: an entry put back. - `restarted`: the history began again (a reload or a restore). Every effect applies **forward**, so a replay is effects in order, and the tape format (#453) needs no direction. - **`Host.recording(doc_id)` / `Host.recordStep(doc_id, step)`**: the owner asks first, so a document nobody records encodes nothing. **`Host.recorder`** is the app's seam: plan step 3's tracks install it. Nothing installs it yet, so this PR changes no behaviour at runtime. - **`Plugin.step_format`** (the owner's encoding, 0 = none) and **`VTable.applyStep(doc, effect, motion, ms)`**: lands a step as a new history entry. A step that doesn't fit what the document holds is an error, and the document is left as it was. `ms` is the seam for showing it made at pace; text lands it at once for now. - **Text** implements both (format 1): - A step is a transaction's edits (LEB128 position, removed bytes, inserted bytes) and the selection after (`textcore.Transaction.encodeStep` / `StepView` / `applyStepTo`). - Removed bytes are kept so every step is checked against the text before anything lands, and so it can be reversed. - `History.end` and `pushComplete` now return what they did (`Committed`), and `lastApplied` / `lastUndone` name the step undo or redo moved. The typing path (`TextEditor.editNotifyEnd`), `replaceRange`, undo, redo, reload and restore all report. **Changed from the plan:** the owner *pushes* steps rather than the host pulling them through an `encodeStep` hook. Pulling needs the host to hear of each step first, which is `SHARED_HISTORY_PLAN`'s `historyStepped`, not built yet (plans#15 is still a proposal). Pushing needs nothing, and only the owner knows whether a keystroke joined its run. When authors land, a step's author is the host's to attach by `id`. I'll amend the plan. ## SDK impact - [ ] None - [ ] Core-only or additive: reaches plugins at the next SDK release - [x] Fingerprint moved: recorded in `sdk/src/version.zig`, `sdk_version` untouched, PR labelled `sdk` - [ ] SDK release: bumps `sdk_version`, lists the `sdk` PRs since the last `sdk-v*` tag For plugin authors: all of it is additive. `step_format` defaults to 0 and `applyStep` to null, so an owner that records nothing changes nothing. Fingerprint `0x92a6bd46850d47c3` → `0x6fc6a45bf3a13b00`. ## Verified - [x] macOS: - `scripts/check-fmt.sh`, `zig build`, `zig build check-web`, `zig build test-sdk-version` and `zig build check-tests-reached` pass. - `zig build test`: 561/562 (1 skipped). `zig build test-integration`: 53/53 steps, 529/529 tests. - A new integration test records a document while typing a key at a time (`added`, `joined`), a command's insert, undo, redo, undo, then an edit after going back. It checks the seven kinds in order, plays them into a fresh untitled document to the same text, checks an unrecorded document reports nothing, and checks a step that doesn't fit is refused with the document untouched. - textcore unit tests: forward and reverse round-trips, refusal, truncation at every length, 4,000 bit flips, and commits reporting joined versus added. - [ ] Windows: not run. - [ ] Linux: CI. - [ ] Web: `check-web` builds. Nothing records there yet. ## Follow-ups - Plan step 3: the host's tracks install the recorder (timing, checkpoints, the focus track, profiling). - Pixi's steps (plan step 4) after the SDK release that carries this. - Showing text's steps typed out at pace (`ms`), and text's `motion` (per-keystroke timing), are seams for later. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
A Debug fizzy at `main` crashed a few seconds after a fizzy-mcp run: ``` panic: index out of bounds: index 0, len 0 sdk/tape/Sequencer.zig:185:23 in advance: `const op = ops[self.cursor];` sdk/replay/LiveDriver.zig:162:25 in frame ``` ## Why `LiveDriver.frame` asks the sequencer for `seq.nextAt()`, which is `inf` when nothing is left. `Sequencer.advance` decided it had reached the target with `next_at > until`, but with both infinite that is false, so it read `ops[cursor]` past the end. In `LiveDriver.frame` only an **empty** tape can be there: a tape that runs out is finished in the same frame (`seq.done()`). An empty live tape is valid. `Tape.validate` has a test for it (`Tape.zig`, "empty"), and builders make them: the agent plugin's `Script.stroke` with no points adds nothing, so a run whose tape steps add no ops plays an empty tape. So I didn't add a guard in the driver or reject empty tapes. The sequencer's end-of-ops test is now explicit (`cursor >= ops.len`), and `now` never takes an infinite target. An empty tape now finishes on its first idle frame (`.finished`), like any other. ## Testing - New `sdk/tape` test, "asked for what is next with nothing left, it has reached the end": it advances an empty tape, and a spent one-op tape, to `nextAt()`. Without the fix it panics with the same `index 0, len 0`. - `scripts/check-fmt.sh`, `zig build test` (91/91 steps), `zig build test-sdk-version` pass locally. `test-integration` and `check-web` are left to CI; the change is std-only sequencer code. No SDK boundary change (the fingerprint is unchanged). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…458) A plugin whose ABI fingerprint differed from the host's while its `sdk_version` matched was reported as **"SDK versions match, but optimize mode does not match"**. That was true only half the time. Under the SDK release train, feature PRs move `recorded_sdk_shape_fingerprint` and leave `sdk_version` alone. A Debug Fizzy built from `main` after an `sdk` PR (#442 after 0.2.27, for example) therefore refused Debug plugins built against `sdk-v0.2.27`, and showed the optimize-mode message even though both were Debug builds. Building the plugins with `zig build --fork=<fizzy>/sdk` fixed it. ## What changes The fingerprint is only `sdk_shape_fingerprint` folded with `optimize_safety_class`. Arch and OS are not part of it, even though the loader's old comment said they were. So the host can tell the two causes apart with no new export: it recomputes the fold for the other class. - `sdk.dylib.optimizeClassOf(plugin_fp)` returns the class the plugin's fingerprint was folded with when its shape matches the host's, and null when the shape differs. It has a unit test. - `AbiBuildEnvMismatch` is split into two errors, in both the desktop and the web loader: - **`AbiOptimizeMismatch`** (same shape, other class). It names both modes: *"built ReleaseFast or ReleaseSmall, but this Fizzy is a Debug build — rebuild the plugin in Debug, or run a release build of Fizzy"*, or the reverse. - **`AbiShapeMismatch`** (different shape, same version): *"same SDK version but a different SDK shape — a Fizzy built from main past a release needs plugins built with zig build --fork=<fizzy>/sdk (or this Fizzy is older than the plugin's SDK)"*. - `AbiMismatch` (different shape and version) is unchanged. - The optimize class is checked first, so a plugin from another release in another mode is reported as the mode problem it actually is. - The store's "Needs release" wording was already correct: it compares the host's class with the store's ReleaseFast-only builds at comptime. Only its doc comment changes, to the new error name. - `plugins/AGENTS.md` gets the new pitfall: a Fizzy built from main past a release needs `--fork`. ## SDK This adds a public function to `sdk/src/dylib.zig` but doesn't move the fingerprint (`test-sdk-version` passes), and `sdk_version` is unchanged. Plugins see no change. ## Verified macOS: `check-fmt`, `zig build`, `zig build test`, `test-sdk-version`, `check-web`, `check-tests-reached`, and `test-integration` (53/53 steps). The two cases are covered by the `optimizeClassOf` unit test. I didn't load a mismatched dylib by hand. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
## What changes `sdk_version` goes from 0.2.27 to 0.2.28. Merging this tags `sdk-v0.2.28` and publishes its tarball. **Why now:** - **Agents:** fizzyedit/agent#16 (an agent's edits don't lock the document, and a document an agent makes opens beside the person) needs #442's `OpenOptions.beside`. - **Pixi's CI:** fizzy `main` dropped `setExplorerNewFilePath` in #449, so every pixi PR fails the `fizzy-main` check until pixi repins to a release that has `files.reveal`. - **Untitled sprites:** #447's `loadUntitledDocument` lets a plugin's untitled documents come back from a crash. Pixi needs a release carrying it to implement that. ## SDK impact - [ ] None - [ ] Core-only or additive: reaches plugins at the next SDK release - [ ] Fingerprint moved: recorded in `sdk/src/version.zig`, `sdk_version` untouched, PR labelled `sdk` - [x] SDK release: bumps `sdk_version`, lists the `sdk` PRs since the last `sdk-v*` tag **`sdk` PRs since `sdk-v0.2.27`:** - **#442:** a plugin can open a document beside the person. `EditorAPI.OpenOptions.beside` (one new defaulted field), and `Host.openBeside` / `endOpenBeside`, so a command run on someone's behalf opens what it makes beside them. - **#447:** an untitled document survives a crash or a restart. A new optional vtable member, `loadUntitledDocument`, for an owner whose untitled documents should come back. `Plugin.bytesPath` is the path to hand `documentBytes` for a document's bytes outside a save. - **#456:** owners report a document's steps, and apply recorded ones (`Step`, a vtable `step_format` and `applyStep`, Host events). All additive: `step_format` defaults to 0 and `applyStep` to null, so an owner that records nothing changes nothing. - **#449:** the file tree keeps its own folders, and `files.reveal` shows new files. `Host.setExplorerNewFilePath(path)` becomes `host.getServiceTyped(sdk.services.files.Api)` then `.reveal(path)`. The `files` service is now version 2. `explorerBranchIsOpen` and `setExplorerBranchOpen` are gone, with no replacement. **Reaching plugins, no fingerprint move:** - **#444:** `core`: a canvas the person hasn't moved stays fitted to its pane, refitting live as the pane changes size. `fitToView` animates there. - **#446:** `core`: `core.widgets.glass_button`, round glass buttons and viewport bubbles. - **#450:** `core`: a kept frost reads again under a region's swap. - **#451:** `core`: a row carried out of a tree is the OS's glass, and stays still. - **#453:** `tape`: steps of a document's history, and a compressed binary form. **`tape.binary.encode` takes a third argument, `Options`** (`.{}` keeps the old output). - **#457:** `tape`: a live tape with no ops finishes instead of crashing. - **#424:** `core`: the glass shader written once in HLSL, its Metal and GLSL generated. - **#458:** `sdk/src/dylib.zig`: a refused plugin says whether its build mode or its SDK shape differs (it blamed the mode for both). - **#435:** `tape`, `replay`: `builtin.target.os`, `.cpu` and `.abi` in place of `builtin.os`. Not in this release: #437 (the Zig 0.17 switch, labelled `sdk`) merged into the `zig/0.17` integration branch, not `main`. `main` still builds with Zig 0.16. ## Verified - [x] macOS: - On this head, `zig build test-sdk-version` passes, and `scripts/pack-sdk.sh` packs `fizzy-sdk-v0.2.28.tar.gz`. - Rebased on `main` after #456, #453, #457, #424 and #458. Re-checked on the new head: `zig build test-sdk-version` passes and `scripts/pack-sdk.sh` packs. - fizzyedit/agent `main` doesn't build against it: `src/Recording.zig:127` calls `tape.binary.encode(gpa, t)`, and #453 added an `Options` argument (its notes say no plugin calls it; the agent's recording does). The fix is `encode(self.gpa, t, .{})`, in the agent's repin. - fizzyedit/pixi `main` doesn't build against it: `src/dialogs/NewFile.zig:230` calls the removed `setExplorerNewFilePath`. Expected; see Follow-ups. - [ ] Windows: - [ ] Linux: CI. - [ ] Web: ## Follow-ups - The repin automation opens `sdk: repin to fizzy SDK 0.2.28` PRs in the store plugins whose build needs it. - **pixi's** opens as a draft, since its build breaks. It needs #449's change: `NewFile.zig` calls `files.reveal`. #449's notes say that change is ready locally, to push with this repin. **atlas** needs the same, for New Note. - **agent's** repin opens as a draft too: it needs `tape.binary.encode(self.gpa, t, .{})` in `src/Recording.zig` (#453). - **Then:** once agent repins, fizzyedit/agent#16 can go ready, and pixi can implement `loadUntitledDocument`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Part of `plans/RECORDING_PLAN.md` (fizzyedit/plans#17), step 3, first half: the tracks, behind a switch. ## What changes With `FIZZY_TRACKS=1`, the app keeps a **track** for each open document whose owner records steps (`app.Tracks`, installed as `Host.recorder`; only text does today): - **Steps with their moments**: each `sdk.Step` (#456) with the time it came on the frame clock, its effect and motion appended to the track's bytes. - **A base**, the document's state (`captureDocumentState`) when the track began. It's taken when the document opens, so the first edit is a step and isn't folded into the base. - **Checkpoints** after 256 steps, 1 MB of steps, or a 30 s pause, each with the document's fingerprint. - **`replayInto(doc_id, target, count)`** puts another document of the same owner where the track was after `count` steps: the nearest state, then the steps since, at once. Where a checkpoint stands at `count`, the replay must match its fingerprint. A replay target records none of it. - A closed document's track is kept for the session, and a reload (`restarted`) starts a new one. - **Which document the person worked in** is noted whenever a step lands in a different document than the last. Looking at a document without editing it isn't seen yet; a `fizzy.document.activated` event from the workbench is the named seam. **Nothing runs on a clock.** A step costs an append when it comes. A document's state is never taken from inside an edit, because owners report steps from inside theirs and the selection may not be settled yet. Instead a track asks, and the next frame takes it at its top (`Tracks.tick`, beside `doc_io.tick`), before anything draws. A frame nothing asked of returns on one branch, and a sleeping app runs nothing. Profiler scopes: `tracks.step`, `tracks.tick`, `tracks.replay`. **A text fix this found:** - Undo, redo and paste set a selection the next draw installs (`pending_sel`). Text's `captureState` and `fingerprint` read the widget's caret instead, from before the edit. - So a checkpoint taken before that draw didn't match its own document, and a demo snapshot taken then had the same problem. - They now read `Document.selection()`: the pending selection when there is one, clamped as the draw clamps it. **Left for the second half (3b), per the plan's "invisible, or it isn't done":** - Sealing steps into chunks deflated off the UI thread, and thinning checkpoints with age. - A cap. - The devtools track view. - The recording-on vs off frame-time check in `test-integration`, and a `bench-replay` track case for the budgets (under 20 µs a pixi stroke, under 2 ms a checkpoint on the UI thread). - Turning tracks on by default once those numbers hold. Until then a base per open text document and every step stay in memory, which is why this is behind a switch. ## SDK impact - [x] None - [ ] Core-only or additive: reaches plugins at the next SDK release - [ ] Fingerprint moved: recorded in `sdk/src/version.zig`, `sdk_version` untouched, PR labelled `sdk` - [ ] SDK release: bumps `sdk_version`, lists the `sdk` PRs since the last `sdk-v*` tag (The text fix is inside the bundled plugin; `test-sdk-version` passes.) ## Verified - [x] macOS: - `scripts/check-fmt.sh`, `zig build`, `zig build check-web`, `zig build test-sdk-version` and `zig build check-tests-reached` pass. - `zig build test`: 562/563 (1 skipped). `zig build test-integration`: 53/53 steps, 530/530 tests. - The new integration test turns tracking on and opens a document, then types two keystrokes, runs 300 inserts and undoes once. It checks: - 303 steps with non-decreasing times, a base, and one checkpoint at step 303; - an undone last step, and the focus noted on that document; - replaying all of it into a fresh document matches its text and the original's fingerprint, through the checkpoint check; - replaying 2 steps from the base gives `ab`; - the target recorded nothing; - a closed document's track is kept. - Not measured yet: that's 3b, before the default flips. - [ ] Windows: not run. - [ ] Linux: CI. - [ ] Web: `check-web` builds. The switch is off there (`envSwitch` is null on wasm). ## Follow-ups - 3b as above. - A `fizzy.document.activated` event for the focus track. - A base kept as the file on disk (path and hash) rather than a copy, for documents opened clean: the plan names it, and it halves a big sprite's memory. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Part of #260 (step 2 of `plans/WORKSPACES_PLAN.md`) ## What changes Behind `FIZZY_WORKSPACES=1`, the workbench's document panes stop being leaves of its own split tree and become views the app places, `fizzy.workspace.<grouping>`, each titled by the document it shows. This is the ground tearing a tab off into its own window (step 5) is built on: once a pane is an ordinary view, a float can hold one. - **Main shows the first workspace** by its keywords, in place of `workbench.workspaces` (which the workbench doesn't register while the flag is on). - **A pane beside another is an app split.** A tab dropped on a pane's edge, Open to the Side and `showBeside` now split the place holding the anchor pane (`Region.splitOn`) and assign the new workspace there. Before, they split the workbench's `DockLayout`. - **An emptied workspace closes its place** (`ViewDrag.withdraw`, new). A minted half slides shut. A float closes. When Main itself empties, it takes the views of the half beside it, as an emptied split does today. The only workspace never goes, and shows the home page. - **Saved in `layout.zon`:** the split, which place holds which workspace, and each pane's documents (`Pane N`, as before). `panes.zon` is neither read nor written while the flag is on; migrating it is step 4. How it fits together: the workbench gets one seam, `Workbench.places` (`beside` / `placed` / `close`), which fizzy's new `src/editor/Workspaces.zig` fills in before the plugin registers. Fizzy also keeps one surface per pane in step with `wb.workspaces`, once a frame after the rebuild. Documents still go into panes through the workbench exactly as now (tabs, drops, previews, placeholders, close flow); that moves to the app in step 3. With the flag off, nothing changes. The new branches are all `places == null`. ## SDK impact - [x] None. `Workbench` is fizzy-internal state shared with its own bundled dylib; the SDK boundary is untouched (`test-sdk-version` passes). ## Verified - [x] macOS: a sandbox profile run with `FIZZY_WORKSPACES=1`, driven through the agent plugin's `fizzy_act` with screenshots at each step: - opened three files; - dragged a tab onto the pane's right drop → `Main` + `Main/r1`, workspace 1 holding the tab; - closed it → `Main/r1` slid shut; - split again, then closed Main's last document → Main took workspace 2 and the split closed; - quit and relaunched → both workspaces and their tabs came back; - dragged a tab from the right workspace onto Main's strip → it went in among Main's tabs and the right half closed. - `zig build`, `zig build test`, `zig build test-integration` (53/53 steps), `zig build check-web`, `zig build test-sdk-version`, `scripts/check-fmt.sh`. - [ ] Windows: - [ ] Linux: - [ ] Web: builds (`check-web`); the flag is never on there (no environment). ## Follow-ups - Step 3: documents through the app (`openFile` into the active workspace; the drop rules for documents). - Step 4: `panes.zon` → `layout.zon`. Until then, a place whose workspace had no documents at quit comes back empty. - Step 5: tear a tab off over the desktop, or drop it on its own workspace's middle, into a float holding a new workspace, and back again. - fizzy's restart-into-a-rebuild (`--handover`) doesn't carry `FIZZY_*` env flags, so it comes back with this flag off. That affects every env flag, not just this one. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Part of `plans/RECORDING_PLAN.md` (fizzyedit/plans#17), step 3, second half. Builds on #459, now merged; `main` is merged in. ## What changes Tracks (#459) now hold a bounded amount and are measured. They stay behind `FIZZY_TRACKS` (or `fizzy.tracks.record`) until the default-on PR. - **Checkpoints are deltas** (`app/state_delta.zig`). A checkpoint is kept as the bytes that differ from the latest whole state: the base, or a checkpoint kept whole because its delta had come to half the document. It's generic over any owner's `captureDocumentState` bytes, so no plugin writes anything for it. - The common prefix and suffix are trimmed, a 64-byte block at a time. - A middle the same length in both (nothing shifted, like a canvas) is kept as the runs of blocks that differ, so strokes far apart are a few runs. - Any other middle (text inserted, which shifts what follows) is kept as it is. - Restoring one rebuilds the whole state from its reference first. - **Checkpoints are weighed.** One is taken only once the steps since the last weigh as much as the last delta did (`state_share`), and then after 256 steps or a 30 s pause. At 8,192 steps one is taken whatever it costs, so a seek never replays more than that. - Before this, a checkpoint was a whole copy every 256 steps by count alone. A 256 KB document held 78 copies of itself after 20,000 steps, and an 8 MB one hit the cap. - **Old checkpoints thin.** Those older than an hour keep one per 4,096 steps. Only deltas go; a whole state is what the deltas after it are made from. - **A cap**, 256 MB in memory. Past it, the oldest ended tracks go first, then all but each open track's newest checkpoint and the whole state it's made from. Steps and bases stay, because undo will walk them; making those smaller is compaction (plan step 12). - **`fizzy.tracks`** reports whether tracks record, what they hold against the cap, how many frames did any track work, and per track its document, owner, steps, checkpoints and bytes. It's devtools' and an agent's view of tracks. - **`fizzy.tracks.record { on }`** turns tracks on or off. - A track keeps its document's handle (stable for its life) instead of asking the host for it, so `Tracks` needs no host, and the frame clock is injectable for tests and the bench. **Measured**, `zig build bench-tracks -Doptimize=ReleaseFast`. The owner is a stand-in whose state is a buffer and whose steps write at scattered offsets, so this is the tracks' own work around an owner's. Text's state is a copy of its text plus a small header, so these stand in fairly for it. Pixi's real costs come with plan step 4. The machine was at load 7–8 from other sessions, so treat the timings as upper bounds; the sizes and counts don't depend on load. | Steps | Step | Document | ns/step* | Checkpoints | Held | Worst replay | |---:|---:|---:|---:|---:|---:|---:| | 2,000 | 24 B | 64 KB | 134 | 7 | 220 KB | 0.011 ms | | 2,000 | 200 B | 256 KB | 262 | 7 | 756 KB | 0.036 ms | | 20,000 | 200 B | 256 KB | 225 | 78 | 5.1 MB | 0.029 ms | | 20,000 | 200 B | 8 MB | 4,650 | 78 | 12.9 MB | 0.6 ms | \* each step with its share of the checkpoints it asks for. **Whole copies against deltas,** for 20,000 strokes on the 8 MB sprite: - **Whole copies:** 29.6 MB held, 2 checkpoints. - **Deltas:** 12.9 MB held, 78 checkpoints. That's the 8 MB base, 4 MB of steps, and about 1 MB of checkpoints. - On the 256 KB sprite: 9.1 MB and 15 checkpoints, now 5.1 MB and 78. **At a frame's top:** - **One checkpoint:** 0.021 ms for a 64 KB document, 0.12 ms for 1 MB, 1.2 ms for 8 MB. The budget is 2 ms; the whole copy cost about the same. - A first version of the codec compared byte by byte and twice over, and took 3 ms for 8 MB. It now compares a block at a time, in one pass. - **Nothing asked:** about 1 ns. The "nothing per frame" claim is also a test rather than a timing. With tracks on in the real app (headless), 120 idle frames do no tracks work (`Tracks.worked` doesn't move). Wall-clock comparisons in Debug CI would be flaky. ## SDK impact - [x] None - [ ] Core-only or additive: reaches plugins at the next SDK release - [ ] Fingerprint moved: recorded in `sdk/src/version.zig`, `sdk_version` untouched, PR labelled `sdk` - [ ] SDK release: bumps `sdk_version`, lists the `sdk` PRs since the last `sdk-v*` tag ## Verified - [x] macOS: - `scripts/check-fmt.sh`, `zig build`, `zig build check-web`, `zig build test-sdk-version`, `zig build check-tests-reached` and `zig build check-bench-replay` pass. - `zig build test`: 568/569 (1 skipped). `zig build test-integration`: 53/53 steps, 540/540 tests. - `fizzy-state-delta-tests` (unit, std-only): one edit is a delta about its size; inserted text; changes far apart on a canvas are a few runs; growing, shrinking and emptying; 500 random edits round-trip; truncation at every length and 4,000 bit flips are errors, never crashes. - New `Tracks` unit tests: - a frame's top works only when asked; - a checkpoint as a delta, kept whole past half the document, then deltas against that; - none until the steps outweigh the last delta, and one at the most a seek replays; - a replay through a delta to its bytes and fingerprint; - thinning after an hour, with an injected clock; - the cap's order: ended tracks first, then older checkpoints. - The integration test now also checks idle frames and `fizzy.tracks`. - Not measured in a running fizzy yet. The shared sandbox fizzy belongs to another session, so that measurement comes with the default-on PR, with pixi's real strokes. - [ ] Windows: not run. - [ ] Linux: CI. - [ ] Web: `check-web` builds. ## Follow-ups - **Default on:** - a measurement in a running fizzy, for text now and pixi after step 4; - the flip, with a setting rather than the switch. - **A memory decision for that PR:** checkpoints are now small, so a track is mostly its base and its steps, held beside the owner's own undo history. That's about what undo holds plus a base. The plan's "undo history plus 10%" target needs plan step 11, where owners page their undo from the track. - **No worker thread for checkpoints.** A checkpoint is taken and diffed on the frame that takes it, on every target: the web has no threads, and a worker would save only the diff (about half of 1.2 ms for 8 MB, on a frame every few hundred strokes), since the capture's copy reads the live document anyway. - If pixi's real numbers ever show that frame running over, the fallback works on the web too: spread the diff over the frames after, a slice each within a small budget. The copy is immutable once taken. - With checkpoints as deltas, there's little left for the plan's "deflate sealed chunks on a worker" to save. That's dropped for the same reason. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Part of #260 (step 3 of `plans/WORKSPACES_PLAN.md`). ## What changes Behind `FIZZY_WORKSPACES=1`, a document is no longer limited to the workbench's panes: carried onto any place, it lands there inside a new workspace, the way a view would land. - **An edge splits the place.** A tab, or a file carried out of the tree, dropped on the edge of the Panel, the Sidebar or any other place splits it on that side; the new half is a workspace holding the document. A file that wasn't open opens straight into it, and what was carried with it (a selection out of the tree) goes too. - **The middle lands it as a view.** An empty place takes the new workspace. A place of tabs (the Panel) adds it as a tab and shows it. A place that shows one view gives the place to the workspace, and the view it replaces goes back to wherever its keywords show it. (A view would trade places with the dropped one, but a document's pane can't take a view.) The middle bubble shows add/replace as it does for views. - **A place's tab strip takes it where it's let go**, as another tab. - **A pane's only tab dropped on its own edge moves there.** Room opens on the other side, as for a view alone in its place: drop it on the right and it sits on the right with an empty place on its left. Before, it moved into a new pane, and the old one closed into it by a round trip that left a stray empty half (below). - **A lone tab carries its workspace.** A document that is its workspace's only tab takes the workspace along: dropped on a plain place, the workspace itself lands there (`State.Documents.alone`), as a drag of the workspace would. So a tab moved right and dropped back on the empty left half's middle joins the halves into one place again, and nothing is drawn anew. Before, a new workspace was made round the document and the old one closed behind it; that drew the document anew twice, and it blinked. A view joined with an empty half now stays the one view rather than becoming a place of tabs (`ViewDrag.join`). - **The app seats documents.** A document no pane holds goes into the workspace it was opened toward. This was the workbench's `rebuildWorkspaces`; it's now fizzy's `Workspaces.seat`. Grouping 0 means the active workspace once workspace 0 has gone, as the plan says. - **Fix: a split half made and closed in the same frame is removed.** `ViewDrag.closeMinted` returned early for a place it couldn't find drawn, so a half opened and emptied before its first draw stood empty for good. Now it's dropped outright, as its comment intended. How: the layout gets one seam, `State.documents` (`house(ids) → view id`). The app makes a view holding the documents, and the layout places it like any view it lands (`ViewDrag.house`, `insertView`). Fizzy's `Workspaces` fills it in and registers the view at once, so the place can select it in the same frame. The workbench gets `Places.open` for the lone-tab case. Step 5's tear-off will hand the same view to a float. With the flag off, `documents` and `places` are null, and the only change is the `closeMinted` fix. ## SDK impact - [x] None (`test-sdk-version` passes). ## Verified - [x] macOS: a sandbox profile run with `FIZZY_WORKSPACES=1`, driven through the agent plugin's `fizzy_act` with screenshots: - `b.txt` onto the Panel's right drop → Panel split, the new half a workspace showing `b.txt`; closing it slid the half shut; - `b.txt` onto the Panel's middle → a `B.TXT` tab beside Output, shown; - `c.txt` onto the Panel's strip between Output and Devtools → a `C.TXT` tab there; closing `c.txt` took that tab away and left the Panel's others; - a single tab onto its own pane's right drop → an empty place on the left, the tab on the right; - with two tabs, one dropped on its pane's right drop → its own workspace on the right, as before. - `zig build`, `zig build test`, `zig build test-integration` (53/53 steps), `zig build check-web`, `zig build test-sdk-version`, `scripts/check-fmt.sh`. - [ ] Windows: - [ ] Linux: - [ ] Web: builds; the flag is never on there. ## Follow-ups - The tab strip, close flow and active workspace are still the workbench's; they move with step 6, when its copy goes. `app/layout/SPLITS.md`'s "a document goes only to a document's slot" is updated then too, since it stays true with the flag off. - For the frame a tab is dropped, the pane it left can still draw that document before it catches up. Seen once in a screenshot; not fixed here. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…#466) ## What changes The region picker (the corner button's popup) listed every view a place could show in one flat grid, in registry order, so nothing said which views were made for the explorer and which for the panel. Each card now sits under the place its keywords make it for: 1. the place being edited, under its own name (opened from the Panel: "Panel" first); 2. each other place the shape declared, by its name ("Sidebar", "Main", …); 3. "Other": views whose keywords no place accepts; 4. the Store, as before. The fit is `sdk.keywords.strength`, the same rule the layout binds a surface by, so the header a card sits under is where its keywords would put it. Ties go to the place being edited. Places are one per keyword group (the rail and the explorer it chooses for are one place), and document panes are never a section. A filter ranks within each section and hides empty ones. Headers are the shape's region names, not fixed words, so a copied shape (studio's "Stack"/"Strip") gets its own without any change here. The Split submenu's Vertical and Horizontal rows, which left their icon column blank, now show the split they make: `columns-2` (side by side) and `rows-2` (stacked). ## SDK impact - [x] None ## Verified - [x] macOS: unit test for the section rule (`Picker.zig`); integration test opening the picker headless from the Panel and then the Sidebar, asserting card order by their `picker:<id>` anchors. All gates pass locally (fmt, build, test, test-integration 53/53 steps, check-web, test-sdk-version). Not yet looked at on screen: the popup needs the window focused. - [ ] Windows: - [ ] Linux: - [ ] Web: ## Follow-ups None planned. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
## What changes A tape or an agent can aim at the picker's controls by name, not by position: | Name | What | |---|---| | `picker.mode:one`, `picker.mode:many` | Single / Multiple | | `picker.action:split`, `:clear`, `:defaults`, `:remove` | the actions, each only while it can be pressed (a dimmed Clear has no name) | | `picker.split:vertical`, `picker.split:horizontal` | the rows of the Split menu | These sit beside the cards' existing `picker:<view id>`, which is now in `docs/AUTOMATION.md`'s anchor table along with the new names. **Why.** The row's buttons come and go with the place. Remove sits where Split sat on a place with nothing to Clear, so a point read off one place's picker can land on a different action in the next. While driving splits through the agent plugin for #467, a click aimed at Split hit Remove twice and closed the place. **How.** - `modeButton` and `actionButton` draw through `namedButton`: `dvui.button`'s body with a `core.anchor.mark`, since `dvui.button` doesn't hand back its widget. - `core.widgets.menuRow` gets an `anchor` option for a row that runs no command (a command row is already `fizzy.command:<id>`). - A dimmed action (Clear with nothing to clear, Defaults with no list) publishes no name: it swallows the click, so `wait_for` / `expect` on a name means the action can run. - `core.anchor.mark` costs nothing unless someone asked for anchors this frame. ## SDK impact - [x] Core-only or additive: `MenuRowOptions.anchor` reaches plugins at the next SDK release (`test-sdk-version` passes; the fingerprint didn't move). ## Verified - [x] macOS: integration test `picker: its controls are named for a tape or an agent to aim at, while they can be pressed` (anchors wanted, the picker drawn over a place: Single, Multiple and Split are named; a dimmed Clear and a shape place's absent Remove are not). - [x] macOS: sandbox fizzy (`--profile`), driven through the agent plugin's `fizzy_run` by name only: - opened Main's picker: `fizzy_screen` lists `picker.mode:one`, `picker.mode:many`, `picker.action:split`, `picker.action:clear` and `picker.action:defaults`; - clicked `picker.action:split`, and the menu listed `picker.split:vertical` and `picker.split:horizontal`; - clicked `picker.split:vertical`, and Main split into Main | Main/r1; - opened Main/r1's picker, `expect`ed `picker.action:remove`, clicked it, and Main was whole again. - `scripts/check-fmt.sh`, `zig build`, `zig build test` (93/93 steps), `zig build test-integration` (53/53 steps), `zig build check-tests-reached`, `zig build check-web`, `zig build test-sdk-version`, `scripts/check-examples.sh`. - [ ] Windows: - [ ] Linux: - [ ] Web: builds ## Follow-ups - `namedButton` repeats `dvui.button`'s body to reach the widget's data, because `dvui.button` doesn't hand it back. A `dvui.button` variant in the dvui-dev fork that exposes it would keep one body (`docs/DEPENDENCIES.md`). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Found while chasing the uneven nested split reported in #464's follow-ups. ## What changes A split of a carded place now opens two even halves. Before, the new half was short by the card's padding on both sides. That covers fizzy's Main and Panel, and every split of their halves. At 2× on macOS: | | before | after | |---|---|---| | Main → Split → Vertical | Main 886 px, Main/r1 854 px | 870, 870 | | then Main/r1 → Split → Horizontal | Main/r1 494 px, Main/r1/b1 462 px | 478, 478 | **Why.** A split's extent is the new half's whole reach, border to border, padding included (`Region.init`). `splitOn` halved the place's recorded `size`, which was its *content* rect. `State.setPlaceMetrics` now records the border box, and it takes the box's `WidgetData`, so all three call sites measure it the same way. A split is the only thing that reads `size` (`placeSize`, and a drag's frozen `Target.size`). **The suspected cause was not it.** #464 suspected the `.branch` arm: it sizes the branch by `extent(b.origin)`, the same name the kept leaf has. But the kept origin is always the leftover half of its own branch (`resize = false`), so it never writes an extent under that name. Inside one tree, `extent(name)` always means the minted leaf's reach along its parent's axis, before and after that leaf is split again. Persistence round-trips too: the nested split came back at the same sizes after a restart. **The 1:3 in #464 comes from its own carry-over.** I reproduced it on #464's head (f927a7f) with its recipe: a `panes.zon` of `Pane 0` | (`Pane 4` over `Pane 5`), relaunched with `FIZZY_WORKSPACES=1`. Main/r1 came out 248 px over Main/r1/b1 708 px. The stored extent for Main/r1/b1 was 354 pt, which is exactly (718 − 10) × 0.5. 718 pt is Main's content height *without* the 236 pt Panel under it. So `settle` measures Main on a frame before the Panel has its room, and hands that height to `splitOnSized`. That fix belongs in #464. ## SDK impact - [x] None (`test-sdk-version` passes; `app/` only) ## Verified - [x] macOS: - new integration test `a split of a carded place, and of its new half, opens even halves`. It uses fizzy's shape: a carded Main with `pane_cards`, in a column, and compares the halves' drawn rects, not the `size` this PR redefines. Before the fix it failed at 187 vs 171. - sandbox fizzy (`--profile`, driven through the agent plugin): Main → Split → Vertical, then Main/r1 → Split → Horizontal, measured as in the table. - `scripts/check-fmt.sh`, `zig build`, `zig build test` (91/91 steps), `zig build test-integration` (53/53 steps), `zig build check-tests-reached`, `zig build check-web`, `zig build test-sdk-version`. - [ ] Windows: - [ ] Linux: - [ ] Web: builds ## Follow-ups - #464: measure Main once the layout under it has settled (or from the window), not on the first frame `settle` runs. Its `splitOnSized` gets the reach fix from this PR for free. - Removing a half that was split again: fixed in #467, stacked on this one. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Stacked on #465 (it reuses that PR's `ColumnCardFrame` test frame); review after it, or retarget to `main` once it merges. This is the follow-up #465 named. ## What changes Take Main | (Main/r1 over Main/r1/b1). Emptying **Main/r1** (carrying its last view to the trash or elsewhere), or picking **Remove** on it, now closes just that half. b1's views move into Main/r1, and the column keeps its name, its width and its link in `layout.zon`. That's the same rule as emptying the half that was split anywhere else (SPLITS.md, "Removing a split"). If b1 is split again too, there's no single place to merge into, and Main/r1 stays, empty. **Before:** Main/r1 is two places under one name: the half Main's split minted, and the half its own split kept. `closeEmptied` saw "minted" and slid shut the branch box registered under that name, which is the whole column. Then `Forest.collapse("Main/r1")` matched the inner *leaf*, so b1 was promoted into Main's split. b1 took the column at an extent that was its height (195 pt in the test). The only link left in `layout.zon` still named the gone `Main/r1`, and b1 had no link, so it was lost on reload. **The model change:** - **`SplitTree.Forest.collapse`** takes the outermost node that carries the name: for a place split again, that's the branch its split made, never the leaf that kept the name. New `isSplit` and `placeLeaves` read the same rule. - **`ViewDrag.closeEmptied`** treats a minted half that was split again as an origin: it merges in its other half, or stays. The **Picker's Remove** goes through it for such a place. - **`Region.persistExtent`** counts a column as kept while *any* place in it holds a view. When the column closes, it forgets every place in it, not only the one whose name the column carries. Before, closing a column with an empty Main/r1 and a full b1 would have left b1's views assigned to a place that no longer existed. It walks the forest only once the place has shut, not every frame. - **The picker** offers no Remove on a half that was split again when its other half is split too: there's nothing to merge, and Remove would only have cleared it. - **A use-after-free while drawing.** When a place was forgotten in the middle of drawing its branch, the tree went on drawing children that `collapse` had just freed. The old code had the same hazard on a rarer path. `persistExtent` now returns whether it dropped the place, and `initTree` and the `.branch` arm draw nothing more of it that frame (a refresh is already queued). The new column-close test caught this: the freed b1 leaf re-stored its extent after the column had cleared it. A leaf that was never split again behaves exactly as before. Every changed path checks `isSplit` first, and `placeLeaves` of a plain leaf is that leaf alone. ## SDK impact - [x] None (`app/` only; `test-sdk-version` passes) ## Verified - [x] macOS: - integration tests (new) on fizzy's forest with a carded Main: - `split: emptying the half that was split again closes only that half`. It failed before the fix: Main/r1 was gone and b1 sat in Main's split. - `split: emptying the half that was split again stays when its other half is split too`. - `split: a column shut by its sash goes only once every place in it is empty`: shut with a view in b1, it stays; emptied, the whole column goes and leaves no assignment or extent behind. - unit tests in `SplitTree.zig`: collapsing a place split again drops its whole branch, and collapsing the minted half of a nested split leaves its origin, with one link. - sandbox fizzy (`--profile`, through the agent plugin, macOS 2×): - Main → Split → Vertical gives 870 \| 870 px. - Main/r1 → Split → Horizontal gives 478 / 478 px, with the column's extent unchanged at 435 pt. - Output into b1, then Main/r1 → Remove gives Main 870 \| Main/r1 870 px. Main/r1 holds Output, and `layout.zon` has one link (`Main/r1` from `Main`, right) and no b1. - Remove on b1 itself still closes just b1, as before. - `scripts/check-fmt.sh`, `zig build`, `zig build test` (91/91 steps), `zig build test-integration` (53/53 steps), `zig build check-tests-reached`, `zig build check-web`, `zig build test-sdk-version`. - [ ] Windows: - [ ] Linux: - [ ] Web: builds ## Follow-ups - **The deeper fix is a name of its own for a branch.** A branch and the half that kept its name share one name, and this PR special-cases that (`isSplit`) where it bites: `closeEmptied`, the picker's Remove and `persistExtent`. Anything else that resolves a place by name (`regionNamed`, which finds the branch box first; `placeSize`) still sees two places. Naming branches separately would need a `layout.zon` migration for saved links, so it's its own change. - Picker buttons named for drivers: #468. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Follow-up to #466, from its review. ## What changes - **A view reads under the place showing it.** #466 sectioned cards by keywords alone, so Files dragged onto the Panel was highlighted in the Panel's picker but listed under "Sidebar" (and the Sidebar's picker still claimed it). A card now sits under the place holding it (`Layout.matchingIn`), and falls back to its keywords only when no place does. Places resolved by name (a split's leaf, a float) are each their own section instead of being folded into a keyword group, so a view put in one reads under its name. - **The edited place always leads.** A split's new leaf accepts no view's keywords, so its picker opened on other places' headers alone. Its own header now comes first, with "Nothing here yet." (or "No matching views here." under a filter). - Each card's section is computed once per frame rather than once per section. ## SDK impact - [x] None ## Verified - [x] macOS: unit tests for the section rule, including a held view and a slot leaf; the picker integration test now assigns Files to the Panel and checks both pickers list it in the Panel's row beside Output (on `main` the Sidebar picker puts Files in a section of its own, so the row check would fail). All gates pass locally (fmt, build, test, test-integration 53/53 steps, check-web, test-sdk-version, check-tests-reached). Not looked at on screen. - [ ] Windows: - [ ] Linux: - [ ] Web: ## Follow-ups None planned. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Part of #260 (step 4 of `plans/WORKSPACES_PLAN.md`). Stacked on #462, which is stacked on #461. ## What changes Behind `FIZZY_WORKSPACES=1`, a saved layout comes back whole. - **An old `panes.zon` becomes the app's splits.** The first time fizzy runs with the flag, the workbench's saved tree is laid out split for split from the place the first workspace is in (Main). Each pane's workspace goes in its half, at the share it had. Then `panes.zon` is deleted: the arrangement is `layout.zon`'s from then on. A subtree whose panes didn't come back is skipped, so its sibling takes the room. - **A place whose workspace didn't come back closes.** A workspace that held no document at quit, or none that could open, isn't restored. Its place used to come back empty; now it closes, as it would have had it emptied. - **`Region.splitOnSized`:** `splitOn` for a place that hasn't been drawn yet, with a share for the new half. A nested tree splits a half made in the same frame. `splitOn` now calls it with the drawn size and half. What's already kept and needed no change: which place holds which workspace and each workspace's documents in order (`layout.zon`, since #461), and each workspace's selected tab (the session, `KeptDocuments.applySelections`). Older fizzy reads the same `layout.zon` it always did; nothing new is written. With the flag off, nothing changes. (The workbench still reads `panes.zon` as it did; only the carry-over deletes it.) ## SDK impact - [x] None (`test-sdk-version` passes). ## Verified - [x] macOS: in a sandbox profile: - ran without the flag and split Main (`Pane 0 | Pane 4`); - hand-edited the saved `panes.zon` into a nested tree (horizontal 0.6: `Pane 0` | vertical 0.5 of `Pane 4`, `Pane 5`) and gave `Pane 5` a document in `layout.zon`; - relaunched with `FIZZY_WORKSPACES=1`: Main came back as `Main` | (`Main/r1` over `Main/r1/b1`), each half showing its workspace's document, the horizontal share 60/40, and `panes.zon` gone. - `zig build`, `zig build test`, `zig build test-integration` (53/53 steps), `zig build check-web`, `zig build test-sdk-version`, `scripts/check-fmt.sh`. - [ ] Windows: - [ ] Linux: - [ ] Web: builds; no `panes.zon` there, and the flag is never on. ## Follow-ups - **A split of a split's new half comes out uneven.** The vertical 0.5 above settled at about 1:3, and splitting a nested half by hand came out uneven the same way. A branch sizes itself in its parent by its origin's extent (`Region`, `.branch`), the same name its kept half reads its own size from along the other axis. That's the app's split code with or without this flag, so it's a separate fix. - Step 5: tear a tab off into a float holding a new workspace (`State.documents` with a float), and back. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Follow-up to #470, from its review. ## What changes - The place being edited, when empty, draws its header and "Nothing here yet." inside a box anchored `picker.empty`, so a test or a demo tape can find it. - The picker integration test checks it: once Files and Output both sit in the Panel, the Sidebar's picker leads with `picker.empty` above the Panel's cards, and the Panel's picker has none. - A comment said the sections were "sorted"; they are computed once. ## SDK impact - [x] None ## Verified - [x] macOS: the picker integration test passes, and fails without the anchor; all gates pass locally (fmt, build, test, test-integration 53/53 steps, check-web, test-sdk-version). - [ ] Windows: - [ ] Linux: - [ ] Web: ## Follow-ups None planned. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Part of `plans/RECORDING_PLAN.md` (fizzyedit/plans#17), step 5, first half: the transport. The hot zone and pixi's history line follow. ## What changes **Undo and redo show a document's transport** (`app.HistoryTransport`): a bar of glass at the bottom centre of that document's own pane. - **What's on it:** its history as a line with a tick per step (while they stand 6 points apart) and a knob where the document is; undo and redo bubbles either side; the step's title with how far along it is ("Edit 2 / 3"). - **The look:** three bubbles of one liquid glass (`core.dialogs.glassBubbles`), as the demo player's transport is. - **The line:** a press on the line's band goes to the step under it, and a drag follows the pointer. That's at most one `historyGoTo` a frame, so a drag across a thousand steps costs a jump a frame. A press on the title does nothing. A drag lasts only while the bar holds the mouse: if the bar stopped drawing mid-drag (a claim came), the drag is over. The bubbles step one at a time. - **Coming and going:** the bar fades in, stays while the pointer is on it or a drag holds it, and goes 3 s after it was last touched. While it shows, it sets one timer for its next change (fully in, starting to go, gone); once gone it sets none and asks nothing of the owner. - **A held undo or redo goes further each press:** one step a press for the first six, then 2, 4, 8, … up to 32. A press the other way starts a new run. Each step is the owner's own `undo`/`redo` command, so anything an owner does around an undo still happens. The bar doesn't show on a claimed document, or when there's nothing to undo or redo. - **The activity feed stays above the bar,** as it does above a claim's pill. The bar's place is computed before either draws (`place`), so they don't overlap even on its first frame. A first version used the bar's rect from the frame before, and they overlapped on the frame it came in. - **Anchors** `history.line:<path>` (its `x` is that fraction of the history), `history.back:<path>` and `history.forward:<path>`, unprefixed like `app/`'s others (`region:`, `picker:`), for tapes and tests. Documented in `docs/AUTOMATION.md`. **For any owner** with `historyLine` and `historyGoTo`, so text has it now. Pixi needs both (it has step ids since fizzyedit/pixi#45). That's in the next half, with the hot zone: the pointer near a view's bottom centre, no button held, a short dwell, bringing the bar up. **The rules** are std-only and tested on their own (`app/history_transport.zig`, `fizzy-history-transport-tests`): when the bar shows and for how long, how far a held key goes, and which step a point on the line is. **Not built yet:** - **Going back then editing still clears redo,** as undo always has. The timeline plan's "going back is a step" (`HISTORY_TIMELINE_PLAN.md` step 4) is what keeps it on the line. - **Authors and times on the line.** Authors come with `SHARED_HISTORY_PLAN`'s step 2; times are in the tracks already. ## SDK impact - [x] None - [ ] Core-only or additive: reaches plugins at the next SDK release - [ ] Fingerprint moved: recorded in `sdk/src/version.zig`, `sdk_version` untouched, PR labelled `sdk` - [ ] SDK release: bumps `sdk_version`, lists the `sdk` PRs since the last `sdk-v*` tag ## Verified - [x] macOS: - `scripts/check-fmt.sh`, `zig build`, `zig build check-web`, `zig build test-sdk-version` and `zig build check-tests-reached` pass. - `zig build test`: 572/573 (1 skipped), including the transport rules. `zig build test-integration`: 53/53 steps, 542/542 tests. - The new integration test makes 40 steps in a text document and undoes. It checks: - the bar shows, at 39; - a live tape clicking the title over the line's start leaves it at 39; - clicking the line's start goes back before every change (0), and clicking redo goes to 1; - a drag along the whole line ends at 40; - ten undos in one frame go 14 steps, landing at 26. - Seen in a running fizzy (Debug, driven through the agent plugin's bridge): the bar over the document undo was pressed in, its label, ticks and knob, and the feed's bubbles above it once its place was computed first. - [ ] Windows: not run. - [ ] Linux: CI. - [ ] Web: `check-web` builds. Not seen there yet. ## Follow-ups - The hot zone, and `historyLine` / `historyGoTo` for pixi (the second half of step 5). - "Going back is a step", so scrubbing back and editing loses nothing. - **The line is asked for each frame the bar shows.** `HistoryLine.version` can't save that, because reading the version means building the line. A `historyVersion` hook (one number, no allocation) is the seam, for when long histories make it show up in the profiler. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Part of fizzyedit/plans#14: `plans/SHARED_HISTORY_PLAN.md`, step 2, as amended in fizzyedit/plans#21. This is the SDK half; the app half (the frame's input author, tapes acting, activity bubbles naming others, the integration check) follows as a PR stacked on this one. ## What changes The host learns who made each step of a document's history. It keeps this on the channel owners already report steps through (`Host.recordStep`, #456), not a second one. - **`sdk.Author`:** the person, an agent, a tape, or nobody known (`unattributed`, a missed site). Identity is kind, plugin and session; the label ("Claude", "the tour") is display only. - **Who is acting:** - `act(author)` and `endAct(outer)`: the innermost act wins. An outermost `act` starts a run (one agent call, one tape op). - `acted()` and `actAgain(stored)`: work put off (pixi's call that waits out the person's stroke) runs as the author and run it was asked in. - `author()`: the innermost act, else the frame's input author (`setInputAuthor`, for the app to set where input is consumed), else `unattributed`. - **`recordStep`:** - keeps who made every step that isn't the person's, per document, by step id, plus the document's top step; - counts steps made with nobody acting (`unattributed_steps`, which the app's tests will assert stays 0); - forwards a step to the recorder only while it records, as before. - Working alone, nothing is kept: a document gets an entry the first time someone else acts on it. - **Queries:** `stepAuthor`, `topStep` and `mayMerge` (whether the acting author may fold an edit into a step; it keeps an agent's keystrokes from joining the person's run of typing). `forgetDocumentAuthors` runs when a document closes, beside `releaseDocumentClaims`. - **`Step.top`:** the entry on top after the step, so Undo can name whose step it takes back. An owner that leaves it 0 still gets a sensible top after `added` and `joined`. - **`docs/PLUGINS.md`:** - owners report every step and gate only the encoding on `recording`; - an **Authors** entry: `act` around commands run on someone's behalf, `actAgain` for deferred work, `mayMerge` for runs. Nothing calls `act` yet. Owners report steps only while recording until the plan's step 3. So nothing a person sees changes in this PR. ## SDK impact - [ ] None - [ ] Core-only or additive: reaches plugins at the next SDK release - [x] Fingerprint moved: recorded in `sdk/src/version.zig` (`0x78f2a3cc371828ef`), `sdk_version` untouched, PR labelled `sdk` - [ ] SDK release: bumps `sdk_version`, lists the `sdk` PRs since the last `sdk-v*` tag **For plugin authors:** additive. `Step` has a new defaulted field `top`, and the host has new functions. Nothing existing changes shape. After the release that carries this: - agent's `agentCall` acts as its connection; - chat acts around its ACP writes; - pixi's waiting calls store `acted()` and run in `actAgain`. ## Verified - [x] macOS: - **Unit tests** in `sdk/src/Host.zig` and `sdk/src/Author.zig`: - authors and the top through added, person, undone and restarted steps; - nesting, runs and `actAgain`; - interning and late labels; - `mayMerge` both ways; - the unattributed count. - `zig build test`: 568/569 passed (1 skipped). `check-tests-reached` passes. - **Builds:** `zig build`, `test-sdk-version`, `check-web` and `test-integration`. Results are in the comment below once they finish. - **Store plugins:** pixi and agent build against this `sdk/`. - [ ] Windows: CI cross-compile. - [ ] Linux: CI. - [ ] Web: `check-web`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Stacked on #474 (the SDK half); review that first. Part of fizzyedit/plans#14: `plans/SHARED_HISTORY_PLAN.md`, step 2, as amended in fizzyedit/plans#21. ## What changes The app now says who is acting, using #474's `Host.act` and `setInputAuthor`. - **The frame's input author.** - `Editor.tick` sets it where input is consumed: around the active document's tick, native menu actions, the layout's draw and floats, then around plugin keybinds, the keymap, overlays and the palette. - It clears it around plugins' `endFrame`. Plugins' `beginFrame` and event delivery run with none, as the plan says: they act for themselves, or are nobody's. - The author is the person's, or a tape's while one drives (`Demo.author`). The demo player and the live driver stop for a real press or key before the frame's input is consumed, so a frame a person takes over is theirs. - **Tapes act.** A tape's command ops run inside `act` as the tape (`Demo`'s stage `command`), and a live tape's as whoever it plays for. A demo's author is its title; a live tape played by nobody in particular is "a tape". - **`automation.Api.playAs(bytes, author)`** (service version 6): a live tape played for an author. An agent's pointer tape (`fizzy_act`) is then the agent's. Plain `play` leaves the tape its own author. The service tells the app through a new `OnAuthor` hook, beside its `Other`. - **Bubbles name agents.** An edit made while an agent acts shows as "Claude · Draw". The person's own edits stay plain, as the plan has it, and so does a demo narrating itself. The plan says "others", but prefixing every bubble of a playing demo with its title cluttered the demo; that's a judgement call for review. - **The integration check.** The whole-editor test asserts the tour adds no unattributed step. Owners report every step only from the plan's step 3, so today it guards the steps reported while recording. ## SDK impact - [ ] None - [x] Core-only or additive: reaches plugins at the next SDK release. The automation service is versioned on its own (`service_version` 5 → 6), so a plugin built for version 5 gets null from `getServiceTyped` on this host and degrades. The agent plugin is the one consumer: it moves to `playAs` with its repin. - [ ] Fingerprint moved - [ ] SDK release ## Verified - [x] macOS: - **Unit test:** an agent-acting edit's activity title is "Claude · Draw", the person's "Draw". - **Gates:** `zig build`, `zig build test`, `check-fmt`. `test-integration`, `check-web` and `test-sdk-version` results are in a comment below once they finish. - [ ] Not tried by hand: a demo's edits attributed while it plays, and an agent's pointer tape through `playAs`. The agent plugin calls `playAs` after the SDK release; that PR will show it end to end. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
A crash fix, with no plan behind it. ## What changes Fizzy crashed on 2026-10-16. It was a Debug build of `main` around #461, at SDK 0.2.28, and it segfaulted in `mem.eql` inside `Layout.State.evictIdFromOthers`. The path was `assign` ← `fizzyAssignSurfaces` ← `Workbench.showBeside` ← `Editor.newFile` ← pixi's `createDocument`. An agent's `pixi.create` was opening a sprite beside the person (`host.openBeside`, #442), into a pane that already had tabs. **Cause:** `State.assign` called `setAssignment`, which frees the region's old id strings. It then evicted each new id from every other region, reading the ids from the **caller's** list. A pane adds a tab by reading its assignment, appending to it and writing it back (`Workspace.addTab`; `removeTab`, `insertTab`, `swapTabId` and `Workspaces.close` work the same way). So the caller's list borrows the strings that were just freed, and the eviction compared against freed memory. Most of the time that read only returns garbage. It faults once the allocator has handed the freed slot's page back to the OS, which is why it took a long session with tabs opened and closed. **Fix:** `assign` now evicts by the region's new, owned copy and its stored key. Nothing it reads after the free can be borrowed from what was freed. A caller's list may therefore borrow from any assignment, its own included. The SDK doc for `assignedSurfaces` (Host and EditorAPI) now says so: "read, edit, write" was already how every caller used it. It is a comment change only, and the fingerprint does not move. The ownership is fixed in `State`, not with a guard in `evictIdFromOthers`, so every caller of the read-edit-write pattern is safe without each one copying first. **Found in review, fixed here too:** - `assign` now takes its own entry's key and list out of the map before evicting. Rewriting another region runs `getOrPut`, which grows the map *before* it looks up even an existing key, so a pointer into the entry could go stale mid-loop. This was a second use-after-free of the same kind. - `evictIdFromOthers` kept at most 32 regions and 32 tabs in fixed buffers and swallowed `setAssignment` errors. A pane of 40 tabs that gave one up came back with 32, with no error. It now uses lists, and its errors reach `assign`'s caller. ## SDK impact - [x] None (doc comments on `assignedSurfaces` only) - [ ] Core-only or additive: reaches plugins at the next SDK release - [ ] Fingerprint moved: recorded in `sdk/src/version.zig`, `sdk_version` untouched, PR labelled `sdk` - [ ] SDK release: bumps `sdk_version`, lists the `sdk` PRs since the last `sdk-v*` tag ## Verified - [x] macOS: - **New test:** "assigning a region a list borrowed from the assignments it replaces" (`tests/integration.zig`). It uses `std.heap.page_allocator`, so every free unmaps and a read of a freed id faults every time. Without the fix it crashes with the same stack as the report (`mem.eql` ← `evictIdFromOthers` ← `assign`). With the fix it passes. - **Two more tests**, each failing before its fix: - "assigning a region whose ids other regions hold, whatever the map's fill" tries every count of regions up to 63, so some count leaves the map full at the rewrite. On the page allocator it segfaulted at the stale `key_ptr`. - "taking one id out of a region of many keeps the rest" expected 39 tabs and found 32. - **Whole-editor test:** the headless test now creates four documents with `host.openBeside()` into two panes, closing every other one. It checks that the person stays in their pane and that the last document is active beside them. That test runs on `smp_allocator`, so it exercises the path but would not reliably catch the read on its own. - **Real run:** a Debug build with `--profile` set to a fresh directory, with pixi and agent `main` built against this tree (`--fork`). Through the agent's `fizzy-mcp`, I ran 8 `pixi.create` calls with paths and 3 `fizzy.close` calls. That ended with two panes, holding `s2, s4` and `s6, s7, s8`, and fizzy kept running. The same sequence run 3 times on a build without the fix did not crash either, because the freed slots' pages weren't unmapped; the page-allocator test is the deterministic proof. - **Gates:** `check-fmt`, `zig build`, `test` and `test-integration` (53/53 steps succeeded, 558 tests), plus `check-web`, `test-sdk-version` and `check-tests-reached`. - [ ] Windows: - [ ] Linux: (CI) - [ ] Web: ## Follow-ups None. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…g joins (#479) Part of fizzyedit/plans#14: `plans/SHARED_HISTORY_PLAN.md`, step 3 (text's half; pixi's follows in its repo). ## What changes - **Text reports every step** through `Host.recordStep`, not only while recording. The host keeps who made each one (#474); working alone, it keeps nothing. Encoding a step's `effect` still happens only while the document is recorded, so a document nobody records costs one call per step. - **Each step carries `top`:** the last applied entry after it (`History.lastApplied`), for added, joined, undone and redone alike. The host knows the top step and its author without asking for the line, which Undo's "Undo Claude's …" label (step 5) needs. - **One hand's typing never joins another's run.** `History.end` takes `may_join`. The editor's edit hook fills it from `Host.mayMerge` on the step on top. When that step is someone else's, VSCode's grouping rule doesn't get to fold the new keystroke into it: an agent typing doesn't extend the person's step, nor the person an agent's. - **Typing calls `documentEdited`,** as its doc comment always said it did. A person's typing runs no command, so it still raises no activity bubble. ## SDK impact - [x] None. Text is built in, and #474's API is used as is. ## Verified - [x] macOS: - **New unit test:** an edit with `may_join = false` starts a step of its own where VSCode's rule would have joined it. The existing history tests pass `true` and behave as before. - **`zig build test`:** 573/574 (1 skipped). `check-tests-reached` passes. - **`test-integration`:** 53/53 steps, 556/556 tests. The whole-editor test's check from #475, that the tour adds no unattributed step, now covers real steps: the tour's typing and commands are each attributed to the tape. Two warnings print, and both are on unmodified `main` too (a demo replay mismatch and a float position). - `check-fmt`, `check-web`, `test-sdk-version`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
## What changes `sdk_version` goes from 0.2.28 to 0.2.29. Merging this tags `sdk-v0.2.29` and publishes its tarball. **Why now:** authors (#474, #475) need a release before plugins can use them: - the agent acts as its connection around each command, and plays its pointer tapes with `playAs`; - chat acts around its writes; - pixi's calls that wait out the person's stroke keep their author (`plans/SHARED_HISTORY_PLAN.md` step 2's plugin PRs). ## SDK impact - [ ] None - [ ] Core-only or additive: reaches plugins at the next SDK release - [ ] Fingerprint moved: recorded in `sdk/src/version.zig`, `sdk_version` untouched, PR labelled `sdk` - [x] SDK release: bumps `sdk_version`, lists the `sdk` PRs since the last `sdk-v*` tag **`sdk` PRs since `sdk-v0.2.28`:** - **#474:** who made each step. - `sdk.Author`; `Host.act` / `endAct` / `acted` / `actAgain` / `author` / `setInputAuthor`. - The host keeps a per-document author map filled in `recordStep`. - `stepAuthor`, `topStep`, `mayMerge`, `forgetDocumentAuthors`, and `Step.top`. - The fingerprint moved. Everything is additive (a defaulted field, new functions). **Reaching plugins, no fingerprint move:** - **#475:** the automation service's `playAs(bytes, author)` (`service_version` 5 → 6). Activity titles name an acting agent ("Claude · Draw"). - **#468:** `core`: `MenuRowOptions.anchor`, a stable name for a menu row's button. ## Verified - [x] macOS: - On this head, `zig build test-sdk-version` passes, and `scripts/pack-sdk.sh` packs `fizzy-sdk-v0.2.29.tar.gz`. - fizzyedit/pixi, fizzyedit/agent and fizzyedit/atlas `main` build against this `sdk/` (`--fork`) with no changes. - [ ] Windows: - [ ] Linux: CI. - [ ] Web: ## Follow-ups - The repin automation opens `sdk: repin to fizzy SDK 0.2.29` PRs in the store plugins whose fingerprint moved. - The agent has no repin automation: its repin is a hand-made PR, carrying the agent's `act` around `agentCall` and `playAs` for its pointer tapes. - Chat's repin carries its `act` around ACP writes. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
## What changes `Tests (Windows)` failed about one run in thirty (4 of the last 140 jobs) with `test runner failed to respond for 1m…` on whichever tiny std-only test was unlucky: `fizzy-glass-look-tests`, `backend-titlebar-tests`, `fizzy-profile-causes-tests`, `fizzy-text-doc-state-tests`, `fizzy-profile-spans-tests`, `fizzy-settings-plugins-zon-tests`. CI now compiles every unit test (`zig build check`) in a step of its own before `zig build test` runs them, so no compiler is alive while a test runs. **The cause is in Zig 0.16's std, not in fizzy.** `processSpawnWindows` (`std/Io/Threaded.zig`) creates each child's pipe ends inheritable and calls `CreateProcess` with handle inheritance on, with no `PROC_THREAD_ATTRIBUTE_HANDLE_LIST` and no lock around it. So a child inherits *every* inheritable handle the build runner holds at that moment, including the pipe ends it is making for its other children. A compiler spawned while a test's pipes exist keeps a copy of that test's stdout write end. After the last test, the runner sends `exit` and waits for stdout EOF under its 60 s response timeout (`std/Build/Step/Run.zig`, `waitZigTest`/`requestNextTest`). The test has passed and exited, but EOF comes only when the compiler exits, so the step fails with "failed to respond" and reports no test as failed. Every failing run had a test compile that took ≥1 min (`fizzy-crash-tests`, `fizzy-singleton-unix-tests`, `fizzy-plugin-store-tests`). After `check`, `zig build test` finds every compile cached and spawns only test binaries, which live for milliseconds. **The transport tests were a red herring.** `fizzy-native-transport-tests` passed (21/21) in every failing run. In the PR #460 failure it ran a minute *after* the hung tests had started. Its `error.Unexpected NTSTATUS=0xc000020d (CONNECTION_RESET)` trace comes from the cancel test: its holder closed the accepted socket with the request unread, Windows answers that with a reset, and Zig 0.16's `netReadWindows` maps every status it doesn't list (this one included, which should be `error.ConnectionResetByPeer`) to `error.Unexpected`, dumping a stack trace on stderr. The holder now reads the request first, so the close is a clean FIN and the trace is gone. **How it was found** (throwaway workflow on `ci/windows-flake-diag`, since deleted): - 40 test-only `zig build test` loops on 4 Windows runners: 0 hangs. 96 loops recompiling every test binary each time (from PowerShell, then again exactly as CI runs it, through Git Bash and `ci-retry.sh`'s `tee`): 0 hangs. Those recompiles take ~4 s each and never produce the minute-long compilers. - A standalone repro with no fizzy code: 8 empty tests, each run beside a 75 s `ping`. On `windows-latest`, `instant-2-tests` failed with `test runner failed to respond for 1m5.202ms`. Without the sleepers it passed, and on `ubuntu-latest` it passed both ways (posix pipes are `O_CLOEXEC`). - Defender real-time protection is off on the runners, so scanning is ruled out. Tried and dropped: `--test-timeout` (it raises the response timeout too, but only hides the wait), `-j1` (slow), and retries (CONTRIBUTING: fix the cause). ## SDK impact - [x] None ## Verified - [x] macOS: `zig build test -Dtest-filter=cancel` passes with the transport change. - [x] Windows: [run 38968162178](https://github.com/fizzyedit/fizzy/actions/runs/38968162178) plus 5 reruns of `Tests (Windows)`, all green: 563/568 passed (5 skipped) each time. `Compile tests` did the compiling, and in `Run tests` every compile was `cached` (the step takes ~12 s), so no compiler ran beside a test. No `NTSTATUS`/`failed command` lines in any of the six logs. At the old ~3% rate, six green runs alone prove little; the evidence is the repro plus that invariant. - [x] Linux: `Tests (Linux)` and the rest of CI green. - [ ] Web: not affected. ## Follow-ups None. The cause is in Zig 0.16's std; this is fizzy's workaround, and the pitfall in `docs/PITFALLS.md` keeps the two steps apart. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Conflicts were main's builtin.target rename (#435) against the same rename on zig/0.17, taken from zig/0.17; check-examples.sh, plugins/text's selection hash and the tape reader taken from main. rewrite.sh re-run; by hand: a multi-character string repeat (`**`) in app/state_delta.zig's test as a comptime loop, the tape reader's new field loop in 0.17's shape, and the recorded fingerprint. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Part of `plans/RECORDING_PLAN.md` (fizzyedit/plans#17), step 6, first half: export a track and play it. The transport's Share button, the export summary and the one-view player follow. ## What changes **`fizzy.tracks.export { path, out, real_time }`** writes a document's track as a deflated `.tape` (`app.Tracks.exportTape`): - **The tape:** a keyframe holding the document's file as it was when the track began, a wait for it to open, then every step at the moment it came. - **Pauses** longer than 2 s play as 0.6 s, so a tape of an hour's drawing is the drawing, not the hour. `real_time` keeps them as they were. Each pause runs from where the step before it ended, recorded and played, so a stroke drawn over 1.5 s is never cut short by the next step arriving early. - The plan put this in the player. Doing it at export makes every player, the web's included, play it the same with no player change. The file then holds the shortened times, unless `real_time` is set. - **Where the file comes from:** the keyframe needs the document's own file bytes as the track began, and a track's base is a restore-in-place state that can't create a document elsewhere. So the export: - takes the live document back to the track's start through its history (`historyGoTo` to the step the track noted when it began, `Track.base_top`); - writes it with its owner's `documentBytes`; - brings it forward again in the same call. Nothing is drawn between, the track is muted, and so is the activity feed. The person's document and its history end where they were (checked by the test). The move forward is checked: if it fails, the export fails (`NotReturned`) and says the rest of the history is a redo away, rather than returning a tape while the document sits at the track's start. - **The caret moves:** going back and forth runs the owner's undo and redo, which leave the selection where the last redone step put it. Keeping the person's view through the rewind needs an owner hook (a `historyGoTo` that leaves the view alone), a follow-up. - **Considered and dropped:** encoding file bytes whenever a track begins, which is a second copy of every open document plus an encode on the frame it opens. And restoring the base into the live document, which drops text's undo history. - **Refused, with the reason,** when the track's start can't be reached: undone past and edited over (checked on the history line before anything moves), a keystroke that joined the step the track began in, or an owner with no history line. - **An untitled document's file** gets its owner's first extension, so it opens with the right owner. **Fizzy plays tapes with steps:** - `playTape` checks each step's owner is loaded and writes that step format (`Demo.stepsCheck`, the `Tape.Check.steps` from #453). - The demo stage applies each step to the document its keyframe opened (`Stage.step`, through the owner's `applyStep`). A step that no longer fits its document pauses the tape with a notice, rather than playing on against a document that has diverged. **Text works now. Pixi needs `historyGoTo`** for the rewind; it gets that with the transport's pixi half (5b). Until then an export of a sprite is refused with `NoHistory`. **What playing still asks:** the demo stage replaces the open documents while it plays, so it waits for unsaved changes first, as every demo does. The one-view player in the next half plays a tape in a tab of its own instead, over nothing of the person's. ## SDK impact - [x] None - [ ] Core-only or additive: reaches plugins at the next SDK release - [ ] Fingerprint moved: recorded in `sdk/src/version.zig`, `sdk_version` untouched, PR labelled `sdk` - [ ] SDK release: bumps `sdk_version`, lists the `sdk` PRs since the last `sdk-v*` tag ## Verified - [x] macOS: - `scripts/check-fmt.sh`, `zig build`, `zig build check-web`, `zig build test-sdk-version` and `zig build check-tests-reached` pass. - `zig build test`: 572/573 (1 skipped). `zig build test-integration`: 53/53 steps, 554/554 tests (after review fixes). - The new integration test records a document (five inserts and an undo) and exports it. It checks: - the export holds 6 steps; - the document's text and history line are as before; - the tape loads under fizzy's steps check, and its keyframe file is the document as the track began (empty, for a new one); - its steps applied in order to a fresh document make the same text; - a track whose start was undone past and edited over is refused, and that document's text is unchanged. (The demo stage itself can't start in that run, which has other unsaved documents.) - **Played on the demo stage in a running fizzy** (Debug, its own profile, driven through the agent plugin's bridge): `notes.txt` opened from disk with three lines; four steps recorded (a line, a mistake, its undo, another line); exported; saved; played with `fizzy.demo.playTape`. The replayed document matched the saved file 4 s in, with the pauses shortened. - [ ] Windows: not run. - [ ] Linux: CI. - [ ] Web: `check-web` builds. Exporting there is refused (no file to write yet). ## Follow-ups - **The second half of step 6:** - a Share button on the transport; - an export summary saying what's in the tape before it's written; - opening a `.tape` as a tab on its own stage, the one-view player, so playing doesn't replace the person's documents or wait for their unsaved changes; - a range of the track rather than all of it. - **Pixi's `historyLine`/`historyGoTo`** (with 5b), which makes its sprites exportable. - **Keeping the person's caret through the rewind:** an owner hook to go to a step without moving the view. - **A demo's document survived a restart** as an ordinary open document (`demo://share/…` came back in the restored session). Noticed while testing; not caused by this change. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Part of fizzyedit/plans#14 — `plans/SHARED_HISTORY_PLAN.md` step 5a, as amended for `plans/CONTROL_SERVER_PLAN.md` (fizzyedit/plans#24). ## What changes `fizzy.undo` and `fizzy.redo` take two optional arguments: - **`path`**: the open document to undo, which need not be the active one. It goes through the owner's `historyLine` and `historyGoTo` (text and pixi both have them), so nothing is focused and the document in front is untouched. An owner with no line undoes only the active document, as before. - **`expect`**: the step id the caller means, the last change for undo or the next one for redo. When another step is there instead, the call refuses and names it: *"step 41 is not the one to undo: that is step 43, Draw, by the person. The document changed since; fix it forward."* So a caller never takes back a step made since it looked. Its answer is `.{ .step, .title, .by }`, where `by` is whose the step was: "the person", or an author's label (`Host.stepAuthor`, #474). Keys, menus and the palette run Undo exactly as before (`run`, with its transport and key repeat). Only a call with arguments takes the new path. This is the refusal the shared-history plan wanted from the agent plugin (fizzyedit/agent#21's `undoesAnother`). As a command, it holds for any caller, and over the control server it becomes a 409 from `POST /commands/fizzy.undo` with no agent code. **One trade-off.** `fizzy.undo`/`redo` are now enabled while any document is open, as text's by-path commands are, because a call may name a document other than the active one. The command palette therefore shows Undo enabled when the active document has nothing to undo; running it then does nothing. The menus still grey it by the active document (`menu_model.canUndo`). An enabled check that sees the call's target is the control plan's open question ("Commands that take a document"), so it isn't decided here. ## SDK impact - [x] None. A doc comment on `Plugin.historyGoTo` notes that `fizzy.undo` reaches it; the fingerprint does not move (`test-sdk-version` passes). ## Verified - Integration (`headless: the whole editor comes up…`), on the open text documents: an author acting as "Claude" writes into `notes.txt` by path while `main.zig` is in front. - A stale `expect` is refused, naming Claude, and changes nothing. - The right `expect` undoes it, answering `.by = "Claude"`, and `main.zig` stays in front, untouched. - Redo and undo again by the same id work. - Gates on macOS: `check-fmt`, `zig build test` (573/574, 1 skipped), `zig build test-integration` (53/53 steps), `check-web`, `test-sdk-version`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Part of `plans/RECORDING_PLAN.md` (fizzyedit/plans#17), step 6, second half (first part): a tape plays in a tab of its own, and the transport shares. Stacked on #478. ## What changes **A tab player (`src/editor/TapeTabs.zig`).** A tape of one document's track opens beside the person's documents, never over them, and plays in at its own pace: - **How it opens:** the tape's keyframe file (the document as its track began) is mounted under a root of its own, `tape://<n>`, and opened like any file. Each step is then applied as its moment comes, through the owner's `applyStep`. - **History:** each step lands on the document's history, so the transport scrubs and steps back through what has played, as on any document. - **Unlike the demo stage,** nothing of the person's is set aside, and the player never waits on their unsaved work. - **Cost:** with nothing playing, a frame costs one check of an empty list. While a tape plays, a `dvui.timer` wakes the app for the next step and nothing else, so the app sleeps between steps. - **Played out,** the document is written into its own mount, so it is clean and closes without asking. An edit the person makes afterwards would make it ask. - **A step that no longer fits** (the person edited the document mid-play) stops the playback with a notice. The document is left theirs. - **A document that never comes** (its load failed) is given 30 s, then the playback lets its mount go. - **Closing the tab** ends the playback and unmounts. - **The root is one nothing holds:** not a mount, and no open document under it. A restart's hand-over or a crash's recovery can bring back an earlier run's `tape://1/…` document. **`Demo: Play a Tape…` routes by what the tape holds.** One document's track goes to a tab; anything else (a demo, a multi-document session, a `.zon`) goes to the demo stage as before. **The transport shares.** `HistoryTransport.draw` takes `DrawOptions{ .share }` and returns `Pressed{ .share }`: - With `.share` set, a fourth bubble (lucide `share-2`, anchor `history.share:<path>`) sits at the bar's end, inside its width. - What a press does is the app's. In fizzy it shows when the document has a track with steps. A press marks the document (`share_pending`), and at the top of the next frame, before anything draws, `Editor.shareDocument` runs. The export walks the document's history back and forth, which shouldn't happen in the middle of its draw. It: - exports the track (`Tracks.exportTape`); - keeps it in the config folder's `tapes/`; - opens it in a tab to play; - says "Saved <name>.tape" on the tape's tab, which the pane turns to. - On the web nothing is written; the tape just opens in its tab. - `Editor.keepTape` is the one writer, and `fizzy.tracks.export` uses it too. **Notices are glass, on the document.** `dvui.toast` draws dvui's opaque pill over the whole window. The share's notices go to the document's own activity feed instead (`Editor.noteOnDocument`, the feed's `push` made public): the same glass pills its commands show in, at its pane's bottom right. That covers the saved tape, a failed share, and a tape that stopped. ## SDK impact - [x] None - [ ] Core-only or additive: reaches plugins at the next SDK release - [ ] Fingerprint moved: recorded in `sdk/src/version.zig`, `sdk_version` untouched, PR labelled `sdk` - [ ] SDK release: bumps `sdk_version`, lists the `sdk` PRs since the last `sdk-v*` tag ## Verified - [x] macOS: - `scripts/check-fmt.sh`, `zig build`, `zig build test`, `zig build check-web`, `zig build test-sdk-version` and `zig build check-tests-reached` pass. - `zig build test-integration`: 53/53 steps, 559/559 tests (rebased on `main`). - #478's export test now plays the exported tape through `TapeTabs` instead of applying its steps by hand. It checks: - the played document at `tape://1/<name>` makes the same text, and the person's document is unchanged; - the played document's history stands at 6; - played out, it is clean and closes with `closeFileID` without a prompt; - the playback and its mount are gone after the close; - the share itself (`share_pending`, as the button sets it) writes one more tape into the config folder's `tapes/` and plays it in `tape://2/<name>` to the same text. - **In a running fizzy** (Debug, its own profile, `FIZZY_TRACKS=1`, driven through the agent plugin's bridge and fizzy's own frame capture): - edited `notes.txt`, then undo and redo brought the bar up with the share bubble at its end; - pressing it wrote `tapes/notes-<time>.tape`, opened a third tab, and the tape played in; - the played text matched the original byte for byte, `.unsaved = false`, and the "Saved <name>.tape" notice showed in glass on the tape's tab; - with an earlier `tape://1` document brought back by crash recovery, the share took `tape://2`. - [ ] Windows: not run. - [ ] Linux: CI. - [ ] Web: `check-web` builds (the share opens the tape without writing it). Not run in a browser. ## Follow-ups - **Mounted documents in the session:** a `tape://` (or `demo://`) document is saved in `layout.zon` and kept by restart hand-over and crash recovery, though its mount is gone. That is generic, not this player's: the session should skip documents on mounts that don't outlive the run. The seam is a mount flag the session reads. - **The tab's name** is the file's (`notes.txt`). A tape tab should say it's a tape. That needs a title hook the workbench reads, or a name on the mount. - **The rest of step 6:** an export summary before writing, and a range of the track rather than all of it. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Part of `plans/RECORDING_PLAN.md` (fizzyedit/plans#17), step 6, the rest: share a range of the track, after the export summary. ## What changes **The share asks first** (`plans/RECORDING_PLAN.md`, "Privacy": export shows what is in it before writing). The transport's share opens `Dialogs.ShareTape`: - what it would share, in one line: *notes.txt: 4 steps over 6 s, by you and Claude. The tape plays in 1 s.*; - the range, as two sliders over the track. Each end says where it falls ("where it began", "after step 12, 3 min 20 s in"); - "Keep every pause as long as it was" (real time); - when the range can't be shared as it stands, why, and no Share button; - Share hands the range to `Editor.shareDocument` at the next frame's top (`share_pending`, now holding the options), as before. **A range of the track** (`Tracks.ExportOptions.from`, `to`). The tape opens on the document as it was after the range's first `from` steps and holds the steps through `to`: - **Where the history stood after any step** (`Tracks.topAfter`) is worked out from the steps the track already keeps: added, joined, undone, redone. Owners don't need to report it. pixi doesn't fill `Step.top` after an undo or redo, so relying on owners would leave pixi out; this way it gets ranges for free. - **Refused, with the reason, when the start can't be reached:** - an undo or redo there reached an entry from before the track began; - a later step joined the entry the range starts on; - the entry was undone and edited over. The checks the track's start already had now apply to any start (`startOf`). `EmptyRange` is new. - **A range from a step** opens on that step: its first step plays at once rather than after the pause before it. - **Timing:** the play-time rule (a pause past 2 s plays as 0.6 s, each measured from where the previous step ended) is one `Timing` that both the export and the summary use. **`Tracks.summarize`** is what the dialog shows: - the range as it will be exported; - the step count; - recorded and played durations; - the distinct authors (`Author.same`); - the refusal, if there is one. It moves nothing. **`fizzy.tracks.export`** takes `from` and `to`, and returns the summary with the path: steps, range, durations, and authors by label (a label-less author by its kind). `history_transport.shareRange` turns the dialog's two slider fractions into a range: at least one step, inside the track. It lives with the transport's other pure rules and their tests, and `app` now exports that module. ## SDK impact - [x] None - [ ] Core-only or additive: reaches plugins at the next SDK release - [ ] Fingerprint moved: recorded in `sdk/src/version.zig`, `sdk_version` untouched, PR labelled `sdk` - [ ] SDK release: bumps `sdk_version`, lists the `sdk` PRs since the last `sdk-v*` tag ## Verified - [x] macOS: - `scripts/check-fmt.sh`, `zig build`, `zig build test`, `zig build check-web`, `zig build test-sdk-version` and `zig build check-tests-reached` (914 tests, all reached) pass. - `zig build test-integration`: 53/53 steps, 560/560 tests. - **Unit tests:** - `topAfter` over added, joined, undone, redone, an overwrite, and an undo past the track's start (unknown); - `shareRange` at both ends and with the ends crossed. - **Integration:** the export test also exports `from = 2` of six steps. It checks: - the result says 4 steps, range 2–6 and one author; - the tape's keyframe file is the document after two steps (`"two\none\n"`) and it holds 4 step ops; - the person's document is unchanged. - **In a running fizzy** (Debug, its own profile, `FIZZY_TRACKS=1`, driven through the agent plugin's bridge): - share on `notes.txt` opened the dialog in its own window, captured through fizzy's own frame capture. It read "notes.txt: 4 steps over 6 s, by someone unknown. The tape plays in 1 s." The steps came from bridge commands with no author, so "someone unknown" is right. - Pressing Share closed the dialog, wrote a tape into `tapes/`, and opened it in `tape://2`. - I didn't drag the range sliders there (the dialog is an OS window of its own, which the bridge's clicks don't reach); the integration test covers the range. - [ ] Windows: not run. - [ ] Linux: CI. - [ ] Web: `check-web` builds. Not run in a browser. ## Follow-ups - **"Only the result's steps"** for text, where typed-then-deleted text matters, is step 12 (compacting): it needs `composeSteps`. - **The summary recomputes each frame** while the dialog is open. It walks the range's steps and the line, which is nothing at today's sizes. If 8-hour tracks make it show, the seam is to cache the summary on the dialog, keyed on the range and the track's step count. - **Fit to View** for a played pixi tape (#444), from the plan's "Playing one". 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Part of `plans/RECORDING_PLAN.md` (fizzyedit/plans#17), step 6: replaces #493's summary dialog with a trim in the tape's own tab, the way macOS's recorder trims a recording. ## Why The dialog asked for a range as two sliders labelled with step numbers and times. Nobody knows which step or second they want; they know what the document looked like. macOS trims in the player: the bar becomes a yellow bracket, and the picture shows wherever a handle is dragged. Fizzy already has the player (the tape tab, #485), so the trim happens there, over the document itself. ## What changes **Share opens the trim.** The transport's share exports the whole track in memory and opens it in a tape tab (`TapeTabs.open` with `.trim`). The tape is played in at once, so the whole of it is there to trim, and nothing is written yet. **The trim bar** (`app/TrimBar.zig`) takes the transport's place on that tab: - **The line:** the tab's history as a line under a yellow bracket over the part kept, with a handle at each end and a white playhead. - **The handles:** dragging one takes the document to that point (`historyGoTo`, one jump a frame at most, as the transport does), so the person sees exactly where the part begins and ends. A press away from the handles moves the playhead inside the part. - **The summary:** over the line, in words: *3 steps, 48 s, by you and Claude*. The authors come from the source track's steps (`Host.stepAuthor`), carried into the tab with the tape. - **Cancel (×):** closes the tab. Nothing was written. - **Play (▶/❚❚):** plays the part kept at the tape's pace, from the playhead (or the part's start), and stops at its end. A timer wakes for each step; nothing runs otherwise. - **Share (✓, yellow):** writes the part as a tape of its own (`TapeTabs.trimmed`), keeps it in `tapes/`, and says "Saved <name>.tape" in glass on the tab. The tab is then an ordinary tape tab: its transport is back, and it is clean, so closing it asks nothing. - The rules (what a press takes, how a drag clamps, at least one step kept) are `history_transport.Trim`, std-only and unit-tested beside the transport's own. **A trimmed tape** is made from the tab's document, not from the source track: - **The keyframe** is the document's file as it stood at the start handle. The tab is taken back there through its history, written by its owner (`documentBytes`) and brought forward again in the same call, unheard by tracks and the activity feed. - **The steps** are the part's, re-timed from its first. - **Any trim start is reachable.** The tab's history is the tape's steps one-to-one and nothing overwrites them, so the "start not in the history" refusals of a track range can't happen here. **Removed:** `Dialogs.ShareTape` and its slider helper (`history_transport.shareRange`). The track-range export from #493 stays: `fizzy.tracks.export { from, to }` and `Tracks.summarize`, for agents and tests. ## SDK impact - [x] None - [ ] Core-only or additive: reaches plugins at the next SDK release - [ ] Fingerprint moved: recorded in `sdk/src/version.zig`, `sdk_version` untouched, PR labelled `sdk` - [ ] SDK release: bumps `sdk_version`, lists the `sdk` PRs since the last `sdk-v*` tag ## Verified - [x] macOS: - `scripts/check-fmt.sh`, `zig build`, `zig build test`, `zig build check-web`, `zig build test-sdk-version` and `zig build check-tests-reached` (915 tests, all reached) pass. - `zig build test-integration`: 53/53 steps, 560/560 tests. - **Unit tests:** `Trim.drag` keeps at least one step and stays inside the history, for both handles and the playhead; `Trim.grab` takes the nearer handle, the playhead away from them, and the press's side for a one-step part. - **Integration:** - share on a document of six steps opens `tape://2/<name>` in trim mode, the same text, with nothing written to `tapes/`, and a summary of "6 steps…"; - trimmed to start after two and shared, exactly one tape is kept. Its keyframe is the document after two steps (`"two\none\n"`) and it holds 4 steps; the tab and the person's document keep their text, and the trim ends; - a second share, cancelled, closes its tab and writes nothing. - **In a running fizzy** (Debug, its own profile, `FIZZY_TRACKS=1`, driven through the agent plugin's bridge, seen through fizzy's frame capture): - share opened the trim bar on the tape's tab; - dragging the start handle halfway took the document to step 3 of 6, and the summary read "3 steps"; - Play (by its `trim.play` anchor) played the document from the handle to the end; - Share wrote one tape and noted it in glass, and the tab was clean; - a second share, cancelled, closed its tab with no tape written. - [ ] Windows: not run. - [ ] Linux: CI. - [ ] Web: `check-web` builds. Not run in a browser. ## Follow-ups - **Mounted documents in the session** (from #485): a restart's hand-over brings `tape://` tabs back without their trim. The session should skip documents on mounts that don't outlive the run. - **Thumbnails along the line**, as macOS's filmstrip: a document preview hook drawn small. That's a seam for owners (pixi's art at a size), not needed to trim. - **Real-time pauses:** the share exports with long pauses shortened; the toggle the dialog had is gone. `fizzy.tracks.export { real_time }` still has it. - **A pane too narrow for the bar** (under about 200 px) shows no trim bar, so its Cancel can't be reached there; closing the tab still works. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Part of `plans/CONTROL_SERVER_PLAN.md` (fizzyedit/plans#22), step 1. ## What changes fizzy answers HTTP/1.1 on a Unix socket, `<config>/control.sock` (`<profile>/control.sock` under `--profile`). The API is the app's commands, with the parameters each already declares. Nothing registers a route. So `curl` drives fizzy, with no agent or MCP tooling in between. Dark behind `FIZZY_CONTROL=1` until the plan's step 4. ``` GET / {"name":"fizzy","version":"0.2.0","sdk_version":"0.2.29"} GET /commands every command: id, title, owner, visibility, enabled, params (kind, required, default, min/max, choices) GET /commands/{id} one of them POST /commands/{id} run it: body = arguments, response = result ``` ```sh curl --unix-socket ~/fizzy-profile/control.sock localhost/commands/text.insert \ -d '{"text":"from curl\n","line":1,"path":"/path/hello.txt"}' {"line":2,"column":1} ``` - **JSON outside, ZON inside** (`app/control/json.zig`). Arguments arrive as JSON and reach `Host.callCommand` as ZON. A string becomes an enum literal where the parameter is an enumeration. Results go back as JSON. Send `Content-Type: application/zon` to send and receive ZON as is. - **HTTP edges:** a request still queued at quit gets a 503 "fizzy is closing" (its socket stays open for it). An oversize body, an `Expect` other than `100-continue`, or a GET or HEAD with a body is refused (413/417/400) before any `100 Continue` and before the app sees it. - **Errors are HTTP's**, each with `{"error": "…"}`: 404 for an unknown command, 400 for arguments that do not fit or a body that is not JSON, 409 for a command not enabled now, 500 for one that ran and failed. - **Transport** (`app/control/Server.zig`): lifted from the agent plugin's socket server (thread per connection, requests handed to the UI thread, inode-checked socket removal), with `std.http.Server` on each stream and keep-alive. `start` does everything that can fail (address, listen, chmod) on the calling thread, so a server that returns is one that listens. The accept loop is an `Io` task that `stop` cancels, with no wake pipe. std interrupts a blocked `accept` that way on every OS. Closing the listener under it is `unreachable` in std's Windows `accept`, and connecting to the path would wake whichever fizzy owns it after a restart's handover. The socket is `chmod 0600`, and that is the authorization: there is no token. Requests are answered at the top of the frame, just before plugins' `beginFrame` (`routes.answerPending`), so that frame sees what they ran. - **Windows target minimum raised to 10 1803** (`build.zig`). std offers AF_UNIX on Windows only to a target whose minimum is `win10_rs4` (`std.Io.net.has_unix_sockets`). Below that, listening fails with `AddressFamilyUnsupported` whatever the machine runs. That means the agent plugin's socket has never worked on Windows either. Every Windows Microsoft still supports is newer than 1803. On Windows the socket is a reparse point `statFile` cannot open, so the inode check is skipped there: a server leaves its socket behind, and the next deletes it before listening. Written down in `app/AGENTS.md`. Measured: a call round-trips in 2–13 ms (one wake, then the next frame). Idle with the server running, `devtools.stats` (called over the socket) reads 0 frames over 3 s. ## SDK impact - [x] None. The app calls `Host.callCommand` as it is. Fingerprint unchanged (`test-sdk-version` passes). - [ ] Core-only or additive: reaches plugins at the next SDK release - [ ] Fingerprint moved: recorded in `sdk/src/version.zig`, `sdk_version` untouched, PR labelled `sdk` - [ ] SDK release: bumps `sdk_version`, lists the `sdk` PRs since the last `sdk-v*` tag ## Verified - [x] macOS: unit tests for `json.zig` and `Server.zig` (a request over a real socket, and the socket's 0600 mode). An integration test covers `routes` over a `Host`: listing, describing, running with JSON and with ZON, and each error status. Live, I ran the built fizzy with `FIZZY_CONTROL=1 --profile` and drove it with curl: `/`, `/commands`, `fizzy.openFiles`, then `text.insert` and `text.read` (the text came back), `fizzy.saveAs` (the file on disk has the line), and the 404 and 400 paths. The socket is gone after quit. Gates: fmt, `zig build`, `test` (577/578, one skip is pre-existing LSP), `test-integration` (53/53 steps), `check-web`, `test-sdk-version`, `check-tests-reached`. - [x] Windows: `zig build check -Dtarget=aarch64-windows-gnu`, then fizzy's own `fizzy-control-server-tests.exe` (5 tests: round trip, 503 at quit, refusals, too-long path, handover) passed 5/5 runs in a Windows 11 ARM64 VM. It ran rather than skipped, so the 1803 minimum took effect. Before the `Io`-cancel change, the same binary panicked in std's `netAcceptWindows`. The x86_64 cross-compile links. Not run: fizzy itself on Windows with the server on. - [ ] Linux: CI. The first run failed to compile the unit test without libc (`std.c` calls), now gone. Cross-compiles for x86_64-linux without libc. - [x] Web: `check-web`. Nothing is added there: the field is `void` on wasm. ## Review An independent review pass found 12 issues. Fixed, each with a test where it can have one: - A request queued at quit got an empty reply instead of its 503. - On Windows, quitting after a restart handover hung, because the wake reached the new fizzy. - Infinite floats and text that is not UTF-8 produced invalid JSON with a 200. - An `Io` wait could be cancelled while its request was still queued. - `100 Continue` was sent before a 413. - JSON `-0.0` was refused. - "listening on" was logged when nothing listened. - `run` and `stop` raced on the wake pipe. - The wake pipe was inherited by children (gone with the pipe). - An explicit `-Dtarget` Windows range ending below 1803 was silently inverted. The build now refuses it with a message. - A GET body was read as the next request. Deferred: a group-writable umask leaves the socket group-connectable between `listen` and `chmod`. It's a microsecond window, only for that umask, and only in the user's own config folder. Closing it means listening inside a 0700 directory, which can come with step 4's `--control-path`. ## Follow-ups - Steps 2–8 of the plan: sessions and SSE events, `/openapi.json`, on by default with `fizzy --control-path` and `docs/CONTROL.md`, `/mcp`, loopback TCP, chat over the server, `single_instance` over this socket. - Claims: `callCommand` does not check `Claim`s (its callers are the holders today). Over HTTP, sessions in step 2 decide who holds what. - An app built on fizzy with its own Windows target needs the same 1803 minimum, or its server logs that it cannot start. `buildApp` could raise it alongside step 4. - There is no quit command, so curl cannot close fizzy. - `fizzyedit/agent`: its socket has the same Windows gap until it moves onto this server (step 5). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
#486 was squash-merged, so zig/0.17 lost main as an ancestor and the next merge would have gone back to #434 as its base. This commit takes #486's original merge commit (46ddcd8) as a second parent and keeps zig/0.17's tree as it is, so main at #469 is the base again. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A clean merge; rewrite.sh re-run over the new code. By hand in #490's app/control: a test's string repeat becomes a splatted array, Ast.parse takes .{ .mode = .zon }, Zoir is read through a pointer, and a test's zon parse takes 0.17's options. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
foxnne
marked this pull request as ready for review
October 18, 2026 00:45
foxnne
force-pushed
the
zig/merge-main-1017b
branch
from
October 18, 2026 00:45
bd2cb76 to
47a0c1e
Compare
2 tasks done
foxnne
added a commit
that referenced
this pull request
Oct 18, 2026
Part of fizzyedit/plans#5 (`plans/ZIG_0_17_PLAN.md`, step 2): the last stand-ins before `zig/0.17` goes to `main`. Stacked on #503; retarget to `zig/0.17` once it lands. ## What changes - **The tape reader drops its 0.16 workaround.** `sdk/tape/binary.zig` fed std's inflate an `Endless` input, a stream's bytes followed by zeros without end. That kept 0.16's inflate off a bug: `tossBitsShort` crashed on a stream that ended inside its last four bytes. 0.17's `tossBitsShort` checks the bits it has before tossing them, so the reader is a plain `std.Io.Reader.fixed` again, and a stream cut short comes back as `Corrupt`. The Adler-32 check stays: 0.17 still leaves the checksum unchecked. - **The readme and `docs/PLUGINS.md` say Zig 0.17.0.** That covers the plugin template's `minimum_zig_version` and the release workflow's `zig-version`. Left for later, on purpose: - `scripts/zig-0.17/` stays until the switch, in case `main` moves again and needs another catch-up. It's deleted in the last PR before the switch. - `check-examples.sh` keeps pointing at the examples' `zig-0.17` branches through the switch. It flips to `main` in a one-line PR right after their 0.17 PRs land, so `main`'s CI is never red in between. ## SDK impact - [x] None: no shape change, and the tape format is unchanged. ## Verified - [x] macOS (arm64, Zig 0.17.0): `zig build test`, 99/99 steps, 583/584 (1 skipped). That includes `fizzy-tape-tests` 62/62, whose "not a whole one" test reads a tape cut short at every byte, deflated and not. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
3 of 4 tasks
foxnne
added a commit
that referenced
this pull request
Oct 18, 2026
Part of fizzyedit/plans#5 (`plans/ZIG_0_17_PLAN.md`, step 2): fizzy, its backend and its bundled plugins move to Zig 0.17. Every piece was reviewed as its own PR into `zig/0.17`, and this PR lands that branch. `main` is already merged in (through #555), so it carries no conflicts. ## What changes **The move to 0.17** (`minimum_zig_version = "0.17.0"` everywhere), as reviewed in: - #437: the switch, scaffolded with marked stand-ins - #438: the build's custom steps become helper programs - #448: release packaging on 0.17, with Velopack 1.2.161 (+ #452's zon arenas) - #488: settings and command schemas read 0.17's `@typeInfo`, with a test that the SDK fingerprint hashes the same facts as on 0.16 - #489: per-pointer capture through `captureMouseGet` - #494: the Windows traps in `docs/PITFALLS.md` - #496: `blitData` without an `extern struct` `@bitCast` - #498: `fizzy.plugin.installBeside`, for a plugin's helper program - #505: the 0.16 inflate workaround goes; the docs say 0.17 - #561: review fixes. A settings payload with pointers is a compile error, and build reads go through `b.root`. - #573: the plugin install helper compiles once, in Debug - the catch-ups with `main` (#486, #503, #558, #574, and the last merge here), each with `scripts/zig-0.17/rewrite.sh` re-run. That script is deleted in the last commit. Most of the diff is mechanical: `zig fmt`'s upgrades (`@backingInt`, `std.lang`, …) and one-answer renames. The hand migrations are the build (configure/make split, no custom steps), the zon arenas, `@typeInfo`, translate-c for `@cImport`, and the `@bitCast` audit. A high-effort review of the whole `main`…`zig/0.17` diff found five things; the three correctness ones are fixed in #561, and one efficiency one in #573. **Also on the branch, merged there by the maintainer:** the windowing work. - #480–#483: plain dvui apps on macOS - #492, #501, #504, #510: OS windows for floating windows, full screen, pointer routing - #546: window materials **Forks:** dvui-dev at its `zig-0.17` head `1b29a16`, which carries fizzy-dev's patches on upstream's 0.17 `main`, including #540, #544 and #563's dvui commits. Also assetpack, md4zig, sdl_zig and zig-objc on their `zig-0.17` branches, and velopack-zig `3d553b7` (tagged `fizzy-zig0.17`). ## Measured macOS arm64, under load from other sessions, each step alternated between the two. 0.16 is `main` #509; 0.17 is `zig/0.17` with #561. | | Zig 0.16 | Zig 0.17 | |---|---|---| | App, cold Debug build | 94.4 s | 78.6 s | | App, warm no-op rebuild | 0.7 s | 0.4 s | | `test-integration` (compile + run) | 108.9 s | 90.4 s | | App, ReleaseFast build | 142.8 s | 131.5 s | | Image plugin, cold standalone build | 11.6 s | 14.0 s (with #573) | | Startup, Debug (median of 5) | 565 ms | 466 ms | | Startup, ReleaseFast (median of 5) | 175 ms | 177 ms | | Text, ReleaseFast, µs/frame: idle / mid / scroll / typing | 305 / 424 / 435 / 403 | 240 / 306 / 286 / 290 | | Markdown, ReleaseFast, 4 docs, median of 5, µs/frame: idle top / mid / scroll / resize | 1177 / 1735 / 2104 / 1319 | 1270 / 1699 / 1833 / 1192 | The one slowdown is markdown idle at the top of a document, +8%. The rest of the plugin build's gap is dvui's own translate-c on 0.17. ## The plan's gate - **ZLS:** no 0.17 release yet (latest 0.16.0). zigtools/zls#3270 is on ZLS's master. Completions across `fizzy_sdk` from a plugin are not checked. - **Minimum OS:** 0.17 needs Darwin 15+ (OS X 10.11); fizzy's macOS minimum is 11.0, so no change. Windows 10+ and Linux 5.10+ are not stated by fizzy. - **Debug leak reports:** still print (`DebugAllocator`, deprecated, unchanged). The move to `SafeAllocator` is a follow-up with its own Debug frame-time check. - **Velopack:** packaging proven by a test release on macOS (signed and notarized), Windows and Linux. An update in place was not tested. - **Every target builds:** in CI. Windows and Linux were not run by hand on the VMs. ## SDK impact - [x] Fingerprint moved (`0x5f9c818a6972efd4`, recorded in `sdk/src/version.zig`); `sdk_version` untouched. `sdk: release 0.2.30` follows this at once. Plugins need Zig 0.17 to build against it: there's no overlap window. - Plugin authors see `minimum_zig_version = "0.17.0"` and `fizzy.plugin.installBeside`. A settings payload holding pointers other than a string is now a compile error. `docs/PLUGINS.md` says 0.17.0. ## Right after this merges 1. fizzyedit/example-app#8 and fizzyedit/example-plugin#13 merge. Then a one-line PR points `scripts/check-examples.sh` back at `main` (it pins the examples' `zig-0.17` branches until then, so `main`'s CI is never red). 2. `sdk: release 0.2.30`. 3. The store plugins' 0.17 PRs repin and release: fizzyedit/pixi#51, zig#15, atlas#16, drive#13, ghostty#14, agent#23, chat#16. ## Verified - [x] macOS (arm64, Zig 0.17.0), on this tree: - `scripts/check-fmt.sh` - `zig build`: 71/71 - `zig build test`: 109/109 steps, 637/638 (1 skipped) - `zig build check-tests-reached`: 998 tests in 122 files - `zig build check-web`: 42/42 - `zig build test-sdk-version`: 35/35 - `zig build test-integration`: 78/78 steps, 608/608 - [x] All seven store plugins built against `zig/0.17`'s `sdk/` and loaded together in a 0.17 fizzy (Debug, sandbox profile). - [ ] Windows, Linux, the example repos: CI. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Part of fizzyedit/plans#5 (
plans/ZIG_0_17_PLAN.md, step 2): catchingzig/0.17up withmainagain, through #490.Please merge by fast-forward, not squash
#486 was squash-merged, which dropped
mainas a parent: a plain merge ofmainnow would have gone back to #434 as its base and re-conflicted every linerewrite.shchanged. So this PR is three commits:46ddcd8), withmainci: compile the unit tests before running them #469 as its second parent. Its tree is identical to zig/0.17: merge main (40 commits, to #469) #486's squash onzig/0.17.record #486's merge of main as a parent:zig/0.17+ (1), keepingzig/0.17's tree exactly (empty diff). This putsmainback in the ancestry.merge main (6 commits, to #490): with ci: compile the unit tests before running them #469 as the base, the merge was clean.Rebased onto
zig/0.17after #494, #496 and #498 merged; the gates below are from the rebased tree.To keep that ancestry:
jj bookmark set zig/0.17 -r 47a0c1e396dcthenjj git push --bookmark zig/0.17(a push to the integration branch, not tomain). A squash still builds, but the next catch-up re-resolves everything again.What changed beyond the merge
scripts/zig-0.17/rewrite.shre-run over the new code (app/control/, from app: control server on a Unix socket, commands over HTTP #490).app/control/:Server.zig: a test's string repeat ("d" ** n) is a splatted array.json.zig:Ast.parsetakes.{ .mode = .zon },Zoiris read through a pointer, and a test'sstd.zon.parsecall takes 0.17's options.zig build test-sdk-versionpasses as recorded.SDK impact
test-sdk-versionpasses as recorded).Verified
scripts/check-fmt.shzig build: 70/70zig build test: 99/99 steps, 583/584 tests (1 skipped)zig build check-tests-reached: 925 tests in 114 fileszig build check-web: 41/41zig build test-sdk-version: 35/35zig build test-integration: 77/77 steps🤖 Generated with Claude Code