Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
ff12fab
app: Undo names whose step it takes, and the bubble after it has a Re…
foxnne Oct 18, 2026
f892f9b
about: the About dialog and Help menu follow the app (#533)
foxnne Oct 18, 2026
99f330a
app: control server streams the app's events (SSE) (#499)
foxnne Oct 18, 2026
f847dc2
layout: corner button stays dark under a modal; Output tabs on the le…
foxnne Oct 18, 2026
04b2f4f
dvui: front-to-back blocks stay above later focus rings; snapshot bui…
foxnne Oct 18, 2026
75ab578
app: document ids are unique when two load at once (#531)
foxnne Oct 18, 2026
bf1b173
menus: plugin menus are clickable in the title bar, and sit before He…
foxnne Oct 18, 2026
573afed
app: a folder walker that watches a large tree within limits (#543)
foxnne Oct 18, 2026
6c6a5d4
crash: report the caller when the PC is outside every module (#539)
foxnne Oct 18, 2026
ef86a08
linux: opening a large folder no longer hangs watching every director…
foxnne Oct 18, 2026
0c4d03a
dvui: front-to-back blocks close by append; one replay order (#544)
foxnne Oct 18, 2026
ad7b518
build: an app built on fizzy packages under its own name and icons (#…
foxnne Oct 18, 2026
f60c1c7
automation: key pills are the transport bar's glass (#545)
foxnne Oct 18, 2026
42215d2
core: tabs and rail icons show a hover look (#542)
foxnne Oct 18, 2026
9df1a04
app: the scrub bar grows into a strip of the document's history as pi…
foxnne Oct 18, 2026
43c816c
app: export a window of the strip, dragged from both sides (#502)
foxnne Oct 18, 2026
6155170
web: start fetching the editor before the page is shown (#515)
foxnne Oct 18, 2026
ae60b4f
web: play a tape from a link, loading the plugins it needs (#514)
foxnne Oct 18, 2026
68b4f6c
sdk: a plugin can replace a command it names, and gets it back on unl…
foxnne Oct 18, 2026
a14e8b1
app: control server describes itself (/openapi.json) (#508)
foxnne Oct 18, 2026
e3a71ea
web: a tape link's plugin wait builds again (#554)
foxnne Oct 18, 2026
4623dd0
app: control server on by default, socket in a private folder (#509)
foxnne Oct 18, 2026
44849f2
zig/0.17: merge main (22 commits, to #509)
foxnne Oct 18, 2026
9945870
sdk: a command may answer in a later frame (#538)
foxnne Oct 18, 2026
46874b5
ci: retry a dependency fetch that failed on DNS or a server error (#553)
foxnne Oct 18, 2026
de25ee5
app: MCP on the control socket (#541)
foxnne Oct 18, 2026
490e5ab
app: an undo under the person's stroke says to try again (#567)
foxnne Oct 18, 2026
485e770
app: record documents' history by default, with a setting to turn it …
foxnne Oct 18, 2026
dea512f
core: tabs show the hand, and a tapped item is left unlit (#563)
foxnne Oct 18, 2026
b4478be
app: a file opened as text moves to its plugin once that plugin loads…
foxnne Oct 18, 2026
c7e55b2
text: going back is a step, so an edit after it loses nothing (#571)
foxnne Oct 18, 2026
8ae9a0a
web: the site's copies of the app serve every demo as a tape (#569)
foxnne Oct 18, 2026
1c60f52
zig/0.17: record #558's merge of main as a parent
foxnne Oct 18, 2026
cfed055
zig/0.17: merge main (9 commits, to #569)
foxnne Oct 18, 2026
57476de
Merge zig/0.17 (#573) into the main catch-up
foxnne Oct 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,13 @@ jobs:
shell: bash
run: bash scripts/ci-retry.sh zig build check-web

# The tapes fizzyed.it serves beside the app: a script or `docs/demos/` tape that no longer
# builds or reads back fails here, not on the deploy after merge.
- name: Build the hosted demo tapes
if: matrix.name != 'Windows'
shell: bash
run: bash scripts/ci-retry.sh zig build demos

# Fizzy on dvui's own SDL3 backend (`-Dnative-backend=sdl3`), where any dvui app on the stock
# backend stands: the platform pieces compile without fizzy's backend and lose what needs it
# (floats' windows, the window skin). It rotted unnoticed while nothing built it. macOS
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/web.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ on:
- "build.zig"
- "build.zig.zon"
- "web/**"
# The tapes served beside the app (`zig-out/web/demos/`).
- "docs/demos/**"
- "scripts/demos.zig"
- ".github/workflows/web.yml"
release:
types: [published]
Expand Down
7 changes: 4 additions & 3 deletions app/App.zig
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ const Host = sdk.Host;
const PluginLoader = @import("root.zig").store.Loader;
const SettingsWatcher = @import("watch/SettingsWatcher.zig");
const ControlServer = @import("control/Server.zig");
const ControlRelay = @import("control/routes.zig").Relay;
const ControlState = @import("control/routes.zig").State;
const ExecutableWatcher = @import("watch/ExecutableWatcher.zig");
const FolderWatcher = @import("watch/FolderWatcher.zig");
const Keymap = @import("keymap/Keymap.zig");
Expand Down Expand Up @@ -268,8 +268,9 @@ settings_watcher: ?SettingsWatcher = null,
/// The control server (`app.control`), while the `control_server` setting has it run: followed
/// once a frame, answered each frame, stopped at quit. Always void on wasm, which has no socket.
control: if (builtin.target.cpu.arch == .wasm32) void else ?ControlServer = if (builtin.target.cpu.arch == .wasm32) {} else null,
/// What relays the app's events to the control server's streams (`control.routes.Relay`).
control_relay: if (builtin.target.cpu.arch == .wasm32) void else ControlRelay = if (builtin.target.cpu.arch == .wasm32) {} else .{},
/// What the control server's routes keep between frames: the relay of the app's events to its
/// streams, and the requests whose commands answer later (`control.routes.State`).
control_state: if (builtin.target.cpu.arch == .wasm32) void else ControlState = if (builtin.target.cpu.arch == .wasm32) {} else .{},

/// Watches the app's own executable while `settings.restart_on_rebuild` is on: a rebuild restarts
/// the app into it, keeping every document. Null while the setting is off, and wherever
Expand Down
4 changes: 3 additions & 1 deletion app/HistoryFrames.zig
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@ pub fn get(self: *const HistoryFrames, top: u64) ?dvui.Texture {
}

pub fn deinit(self: *HistoryFrames, gpa: std.mem.Allocator) void {
for (self.frames.values()) |t| dvui.textureDestroyLater(t);
// Destroyed with a frame, and only in one: let go of at teardown (the app quitting) there is
// no window, and the GPU goes with the process.
if (dvui.current_window != null) for (self.frames.values()) |t| dvui.textureDestroyLater(t);
self.frames.deinit(gpa);
}
6 changes: 3 additions & 3 deletions app/Tracks.zig
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,9 @@
//! open track's newest checkpoint (`shed`); steps and bases stay, since undo will walk them.
//! Taking and diffing a checkpoint happen on the frame that takes it, on every target (the web has
//! no threads); were that ever too long, the diff would be spread over the frames after, a slice
//! each, the copy being immutable once taken. Until tracks are on by default they are behind `on`
//! (fizzy: `FIZZY_TRACKS=1`). `zig build bench-tracks` measures what a step, a checkpoint and a
//! replay cost.
//! each, the copy being immutable once taken. They record while `on`: in fizzy by default, as its
//! setting says (`record_history`, `FIZZY_TRACKS` over it). `zig build bench-tracks` measures what
//! a step, a checkpoint and a replay cost.
const Tracks = @This();

const std = @import("std");
Expand Down
46 changes: 37 additions & 9 deletions app/control/Server.zig
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,9 @@ socket_inode: ?Io.File.INode = null,
/// Requests waiting for the UI thread, under `lock`.
lock: Io.Mutex = .init,
pending: std.ArrayListUnmanaged(*Request) = .empty,
/// Requests the UI thread took and has not answered yet (a command answering in a later frame),
/// under `lock`: `stop` answers them too.
outstanding: std.ArrayListUnmanaged(*Request) = .empty,
/// Connections streaming events (`publish`), under `lock`.
streams: std.ArrayListUnmanaged(*Stream) = .empty,

Expand Down Expand Up @@ -135,14 +138,17 @@ pub const Request = struct {
const topics = topicsOf(self.arena, self.target) catch
return self.answer(server, .internal_server_error, "text/plain", "out of memory\n");
self.stream = .{ .topics = topics, .conn = self.conn };
{
const added = added: {
server.lock.lockUncancelable(server.io);
defer server.lock.unlock(server.io);
server.streams.append(server.gpa, &self.stream) catch
return self.answer(server, .internal_server_error, "text/plain", "out of memory\n");
server.streams.append(server.gpa, &self.stream) catch break :added false;
self.conn.streaming = true;
}
break :added true;
};
// Answered outside the lock, which `answer` takes.
if (!added) return self.answer(server, .internal_server_error, "text/plain", "out of memory\n");
self.response = .{ .stream = true, .status = .ok };
server.settle(self);
self.done.set(server.io);
}

Expand All @@ -154,6 +160,7 @@ pub const Request = struct {
.body = server.gpa.dupe(u8, body) catch null,
};
if (self.response.body == null) self.response.status = .internal_server_error;
server.settle(self);
self.done.set(server.io);
}
};
Expand Down Expand Up @@ -212,11 +219,23 @@ pub fn take(self: *Server, out: *std.ArrayListUnmanaged(*Request)) usize {
self.lock.lockUncancelable(self.io);
defer self.lock.unlock(self.io);
// Out of memory: left queued for the next frame, never dropped unanswered.
self.outstanding.ensureUnusedCapacity(self.gpa, self.pending.items.len) catch return self.streams.items.len;
out.appendSlice(self.gpa, self.pending.items) catch return self.streams.items.len;
self.outstanding.appendSliceAssumeCapacity(self.pending.items);
self.pending.clearRetainingCapacity();
return self.streams.items.len;
}

/// `req` is answered: no longer `outstanding`.
fn settle(self: *Server, req: *Request) void {
self.lock.lockUncancelable(self.io);
defer self.lock.unlock(self.io);
for (self.outstanding.items, 0..) |r, i| if (r == req) {
_ = self.outstanding.swapRemove(i);
return;
};
}

/// Whether any stream wants the event `name`: the caller skips building a message nobody reads.
pub fn wants(self: *Server, name: []const u8) bool {
self.lock.lockUncancelable(self.io);
Expand Down Expand Up @@ -252,17 +271,25 @@ pub fn publish(self: *Server, name: []const u8, json: []const u8) void {
pub fn stop(self: *Server) void {
self.stopping.store(true, .release);
// The connections whose requests this answers ("fizzy is closing"): each writes it and ends.
// Queued, or taken and still to be answered (a command answering later).
var answered: std.ArrayListUnmanaged(*Conn) = .empty;
defer answered.deinit(self.gpa);
// Moved out whole, not copied: running out of memory here must not drop one unanswered, whose
// task would then wait for good.
var queued: std.ArrayListUnmanaged(*Request) = .empty;
defer queued.deinit(self.gpa);
var taken: std.ArrayListUnmanaged(*Request) = .empty;
defer taken.deinit(self.gpa);
{
self.lock.lockUncancelable(self.io);
defer self.lock.unlock(self.io);
for (self.pending.items) |r| {
answered.append(self.gpa, r.conn) catch {};
r.answer(self, .service_unavailable, "text/plain", "the control server is stopping\n");
}
self.pending.clearRetainingCapacity();
std.mem.swap(std.ArrayListUnmanaged(*Request), &queued, &self.pending);
std.mem.swap(std.ArrayListUnmanaged(*Request), &taken, &self.outstanding);
}
for ([_][]const *Request{ queued.items, taken.items }) |owed| for (owed) |r| {
answered.append(self.gpa, r.conn) catch {};
r.answer(self, .service_unavailable, "text/plain", "the control server is stopping\n");
};
if (self.accept_task) |*t| t.cancel(self.io);
self.accept_task = null;
// No more are accepted, so `conns` holds still. Every other task, reading its next request or
Expand All @@ -282,6 +309,7 @@ pub fn stop(self: *Server) void {
} else |_| {}
}
self.pending.deinit(self.gpa);
self.outstanding.deinit(self.gpa);
self.streams.deinit(self.gpa);
}

Expand Down
186 changes: 186 additions & 0 deletions app/control/mcp.zig
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
//! MCP on the control socket: `POST /mcp` speaks the Model Context Protocol's Streamable HTTP
//! transport, a JSON-RPC message per request answered as JSON, so an agent reaches the same
//! commands curl does (plans/CONTROL_SERVER_PLAN.md, step 5). Its tools are the app's commands:
//! `tools/list` from the registry, each tool's input schema the one `/openapi.json` gives the
//! command's body (`openapi.bodySchema`), and `tools/call` through `Host.callCommandAwaiting`, so a
//! command that answers in a later frame answers its call then.
//!
//! What it does not do yet: server-sent notifications (no `GET /mcp` stream: the tool list is
//! read when asked), sessions (`Mcp-Session-Id`), resources and prompts.
const std = @import("std");
const sdk = @import("fizzy_sdk");
const AppInfo = @import("../AppInfo.zig");
const json = @import("json.zig");
const openapi = @import("openapi.zig");

const Stringify = std.json.Stringify;

/// The protocol versions this answers, newest first: an `initialize` asking for another gets the
/// newest, as the protocol says. 2025-03-26 asked servers to take JSON-RPC batches, which 2025-06-18
/// dropped: none are taken here (no client sends them), a deliberate gap.
const versions = [_][]const u8{ "2025-06-18", "2025-03-26" };

/// What to send back for one message.
pub const Reply = union(enum) {
/// A JSON-RPC response, as the body.
now: []const u8,
/// A notification: answered `202 Accepted`, no body.
none,
/// A tool whose command answers later: `ticket` names it, and `id` is the request's id as JSON
/// (in the arena), for `result` to answer with once it comes.
later: struct { ticket: u32, id: []const u8 },
};

/// Answer one JSON-RPC message, `body`. Everything returned lives in `arena`. `sink` hears a
/// command that answers later; without one, such a call is answered as started.
pub fn handle(host: *sdk.Host, body: []const u8, arena: std.mem.Allocator, sink: ?sdk.Host.CallSink) error{OutOfMemory}!Reply {
// Numbers kept as written, so an id comes back exactly as it was sent.
const value = std.json.parseFromSliceLeaky(std.json.Value, arena, body, .{ .parse_numbers = false }) catch |err| switch (err) {
error.OutOfMemory => return error.OutOfMemory,
else => return .{ .now = try failure(arena, "null", -32700, "the body is not JSON") },
};
if (value != .object) return .{ .now = try failure(arena, "null", -32600, "one JSON-RPC message per request: an object, not a batch") };
const msg = value.object;
const method = if (msg.get("method")) |m| if (m == .string) m.string else null else null;
const id_value = msg.get("id");
// No id: a notification. No method: a response to a request of ours. Neither is answered.
if (id_value == null or method == null) return .none;
const id = switch (id_value.?) {
.string, .integer, .number_string => try std.json.Stringify.valueAlloc(arena, id_value.?, .{}),
else => return .{ .now = try failure(arena, "null", -32600, "an id is a string or a number") },
};
const name = method.?;
const params: ?std.json.ObjectMap = if (msg.get("params")) |p| if (p == .object) p.object else null else null;

if (std.mem.eql(u8, name, "initialize")) return .{ .now = try initialize(host, arena, id, params) };
if (std.mem.eql(u8, name, "ping")) return .{ .now = try result(arena, id, "{}") };
if (std.mem.eql(u8, name, "tools/list")) return .{ .now = try listTools(host, arena, id) };
if (std.mem.eql(u8, name, "tools/call")) return callTool(host, arena, id, params, sink);
return .{ .now = try failure(arena, id, -32601, try std.fmt.allocPrint(arena, "no method {s}", .{name})) };
}

/// A tool call's answer once its command answers later (`Reply.later`).
pub fn outcomeReply(arena: std.mem.Allocator, id: []const u8, outcome: sdk.Host.CommandOutcome) error{OutOfMemory}![]const u8 {
return switch (outcome) {
.ok => |zon| {
// The result as JSON text, which reads as it is; ZON JSON cannot say goes as it is.
const text = json.jsonFromZon(arena, zon) catch |err| switch (err) {
error.OutOfMemory => return error.OutOfMemory,
error.NotUtf8 => return toolResult(arena, id, "the result holds text that is not UTF-8", true),
// Not ZON: as it is, which JSON can carry while it is UTF-8.
else => if (std.unicode.utf8ValidateSlice(zon orelse "")) zon orelse "null" else return toolResult(arena, id, "the result is not ZON", true),
};
return toolResult(arena, id, text, false);
},
.unknown => failure(arena, id, -32602, "no such tool"),
.disabled => toolResult(arena, id, "the command is not enabled now", true),
.bad_args => |m| toolResult(arena, id, m, true),
.failed => |m| toolResult(arena, id, m, true),
};
}

fn initialize(host: *sdk.Host, arena: std.mem.Allocator, id: []const u8, params: ?std.json.ObjectMap) error{OutOfMemory}![]const u8 {
const asked = if (params) |p| if (p.get("protocolVersion")) |v| if (v == .string) v.string else "" else "" else "";
const version = for (versions) |v| {
if (std.mem.eql(u8, v, asked)) break v;
} else versions[0];
const body = try Stringify.valueAlloc(arena, .{
.protocolVersion = version,
.capabilities = .{ .tools = .{ .listChanged = false } },
.serverInfo = .{ .name = AppInfo.current.name, .title = AppInfo.current.display_name, .version = AppInfo.current.version },
.instructions = try instructions(host, arena),
}, .{});
return result(arena, id, body);
}

/// What a model is told on connecting: what the tools are, and the app's own screen and settled
/// commands when it has them (fizzy does; an app built on fizzy may not).
fn instructions(host: *sdk.Host, arena: std.mem.Allocator) error{OutOfMemory}![]const u8 {
var out: std.Io.Writer.Allocating = .init(arena);
const w = &out.writer;
w.writeAll("Each tool is one of " ++ AppInfo.current.display_name ++ "'s commands, the same ones its menus, " ++
"keybindings and plugins run. A command acting on a document takes its path, or acts on the active one.") catch return error.OutOfMemory;
for ([_][2][]const u8{
.{ "fizzy.screen", " fizzy.screen says what is on screen." },
.{ "fizzy.settled", " fizzy.settled waits until nothing is loading or animating." },
}) |t| if (host.command(t[0]) != null) w.writeAll(t[1]) catch return error.OutOfMemory;
return out.written();
}

fn listTools(host: *sdk.Host, arena: std.mem.Allocator, id: []const u8) error{OutOfMemory}![]const u8 {
var out: std.Io.Writer.Allocating = .init(arena);
var s: Stringify = .{ .writer = &out.writer };
writeTools(&s, host, arena) catch return error.OutOfMemory;
return result(arena, id, out.written());
}

fn writeTools(s: *Stringify, host: *sdk.Host, arena: std.mem.Allocator) openapi.Error!void {
try s.beginObject();
try s.objectField("tools");
try s.beginArray();
for (host.commands.items, 0..) |*c, i| {
// Runnable, and the first of its id: the one `callCommand` runs.
if (c.owner == null) continue;
const first = for (host.commands.items[0..i]) |*o| {
if (std.mem.eql(u8, o.id, c.id)) break false;
} else true;
if (!first) continue;
try s.beginObject();
try s.objectField("name");
try s.write(c.id);
try s.objectField("title");
try s.write(c.title);
try s.objectField("description");
try s.write(try std.fmt.allocPrint(arena, "{s} ({s}'s command{s})", .{
c.title,
c.owner.?.id,
if (c.visibility == .internal) ", for programs" else "",
}));
try s.objectField("inputSchema");
try openapi.bodySchema(s, c, arena);
try s.endObject();
}
try s.endArray();
try s.endObject();
}

fn callTool(host: *sdk.Host, arena: std.mem.Allocator, id: []const u8, params: ?std.json.ObjectMap, sink: ?sdk.Host.CallSink) error{OutOfMemory}!Reply {
const p = params orelse return .{ .now = try failure(arena, id, -32602, "tools/call takes .name and .arguments") };
const name = if (p.get("name")) |n| if (n == .string) n.string else null else null;
const tool = name orelse return .{ .now = try failure(arena, id, -32602, "tools/call needs a name") };
const c = host.command(tool) orelse return .{ .now = try failure(arena, id, -32602, try std.fmt.allocPrint(arena, "no tool {s}", .{tool})) };
// The arguments, through the same JSON-to-ZON edge as a REST call (`json.zig`).
// Absent and null are none.
const arguments = if (p.get("arguments")) |a| if (a == .null) "{}" else try Stringify.valueAlloc(arena, a, .{}) else "{}";
var enum_keys: std.ArrayListUnmanaged([]const u8) = .empty;
for (c.params) |param| if (param.kind == .enumeration) try enum_keys.append(arena, param.key);
const args = json.zonFromJson(arena, arguments, enum_keys.items) catch |err| switch (err) {
error.OutOfMemory => return error.OutOfMemory,
else => return .{ .now = try toolResult(arena, id, "the arguments are an object keyed by the tool's input schema", true) },
};
return switch (host.callCommandAwaiting(c.id, args, arena, sink)) {
.now => |outcome| .{ .now = try outcomeReply(arena, id, outcome) },
.later => |ticket| if (sink != null)
.{ .later = .{ .ticket = ticket, .id = id } }
else
.{ .now = try toolResult(arena, id, "started; its answer comes later", false) },
};
}

fn toolResult(arena: std.mem.Allocator, id: []const u8, text: []const u8, is_error: bool) error{OutOfMemory}![]const u8 {
const body = try Stringify.valueAlloc(arena, .{
.content = .{.{ .type = "text", .text = text }},
.isError = is_error,
}, .{});
return result(arena, id, body);
}

/// `{"jsonrpc":"2.0","id":<id>,"result":<body>}`: `id` and `body` already JSON.
fn result(arena: std.mem.Allocator, id: []const u8, body: []const u8) error{OutOfMemory}![]const u8 {
return std.fmt.allocPrint(arena, "{{\"jsonrpc\":\"2.0\",\"id\":{s},\"result\":{s}}}", .{ id, body });
}

fn failure(arena: std.mem.Allocator, id: []const u8, code: i32, message: []const u8) error{OutOfMemory}![]const u8 {
const err = try Stringify.valueAlloc(arena, .{ .code = code, .message = message }, .{});
return std.fmt.allocPrint(arena, "{{\"jsonrpc\":\"2.0\",\"id\":{s},\"error\":{s}}}", .{ id, err });
}
Loading
Loading