Repository navigation
zig/0.17: merge main (40 commits, to #469) - #486
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>
2 of 3 tasks
foxnne
added this pull request to stack #491
October 17, 2026 17:16
foxnne
added a commit
that referenced
this pull request
Oct 17, 2026
Part of fizzyedit/plans#5 (`plans/ZIG_0_17_PLAN.md`, step 2, follow-up (c): `@typeInfo`, with the fingerprint-equality test). Stacked on #486. ## What changes - **The settings and command schema builders read 0.17's `@typeInfo` directly.** `sdk/src/settings.zig` and `sdk/src/Command.zig` loop over `field_names`, `field_types` and `field_attrs`, and take a cell's default with `FieldAttributes.defaultValue`. - This replaces the `ZIG017` stand-in that rebuilt 0.16-shaped field records. `sdk/src/zig017_fields.zig` is deleted. - The bare-payload structs (`Plain`) pass `@typeInfo`'s own name slice to `@Struct` instead of copying it. - **The proof the plan asked for.** `fingerprint.zig` was translated loop for loop in #437, and this adds the check: a test hashing a fixed set of types, one of each kind the hashes look into, at a depth that reaches them all. Both hashes must equal the values **Zig 0.16** computed for the same types, with the file as it was on `main` (7b538aa): - `hashAllShape`, the target-invariant hash that's recorded: `0x2ae9f3848d7d3d9f`. - `hashAll`, on fixed-width types only, since it folds in sizes: `0xc0b59f8f6303fb84`. - The 0.16 run used `main`'s file with Zig 0.16.0 and the 0.17 run uses this branch's with Zig 0.17.0; the values match. `ZIG017` markers left on the branch: the two per-pointer-capture notes (dvui#1014) and `check-examples.sh`'s branch pins, which go back to `main` at the merge. ## SDK impact - [x] None. `zig build test-sdk-version` passes with the fingerprint #486 recorded: the schema shapes plugins see are unchanged. ## Verified - [x] macOS (arm64, Zig 0.17.0): - `scripts/check-fmt.sh` - `zig build` - `zig build test`: 95/95 steps - `zig build check-tests-reached`: 120/120 - `zig build check-web`: 41/41 - `zig build test-sdk-version`: 35/35 - `zig build test-integration`: 77/77 steps - [ ] Windows, Linux: CI. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2 of 3 tasks
foxnne
added a commit
that referenced
this pull request
Oct 18, 2026
#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>
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 withmain. It was 40 commits behind, up to #469.Please merge this one by fast-forward, not squash
This PR is a merge commit with
mainas its second parent. Squashing it would drop that parent. The next catch-up would then find the old merge base, and every linerewrite.shchanged inmain's code would conflict again.Once CI is green, the clean way is a fast-forward of
zig/0.17to this commit:jj bookmark set zig/0.17 -r <this commit>thenjj git push --bookmark zig/0.17. That's a push to the integration branch, not tomain. Your call: squash also works, at the cost of re-resolving these lines next time.What changed beyond the merge
main'sbuiltin.targetrename (zig: builtin.target.os, .cpu and .abi in place of builtin.os #435) against the same rename already onzig/0.17; those takezig/0.17's side. These files takemain's side, thenrewrite.sh:scripts/check-examples.sh:main's fuller bsdtar fix.plugins/text/src/Document.zig: the selection hash.sdk/tape/binary.zig: the header now written early, and the versioned field read.sdk/src/version.zig: re-recorded (below).scripts/zig-0.17/rewrite.shre-run over the codemainbrought in.app/state_delta.zig: a test's multi-character string repeat ("…" ** 100, gone in 0.17) is now a comptime loop.sdk/tape/binary.zig: the tape reader's new indexed field loop, in 0.17'sfield_namesshape.recorded_sdk_shape_fingerprintis0x0b0e62b7eac39f6d:main's SDK changes since the switch, on 0.17. It is released with the merge tomain, as before.SDK impact
sdk/src/version.zig,sdk_versionuntouchedVerified
scripts/check-fmt.shzig buildzig build test: 95/95 steps, 232/232 testszig build check-tests-reached: 120/120zig build check-web: 41/41zig build test-sdk-version: 35/35zig build test-integration: 77/77 steps, 559/559 testscheck-examples.shwasn't run locally because the machine is under load from other sessions.🤖 Generated with Claude Code