From dd99a6f2cd887ee0c2a665f2206765ec75f9ea82 Mon Sep 17 00:00:00 2001 From: foxnne Date: Fri, 16 Oct 2026 16:40:07 -0500 Subject: [PATCH 1/2] sdk: who made each step: Author, act, and the host's author map The first half of plans/SHARED_HISTORY_PLAN.md's step 2 (as amended in fizzyedit/plans#21): who made each step of a document's history, kept by the host on the channel owners already report through (Host.recordStep, fizzy#456). - sdk.Author: the person, an agent, a tape, or nobody known; identity is kind, plugin and session. - Host.act / endAct / acted / actAgain / author: who is acting. Innermost wins; an outermost act starts a run; work put off acts again as its stored author and run. - Host.setInputAuthor: the frame's input author, for the app to set where input is consumed. - Host.recordStep keeps who made every step not the person's, per document, by step id, and the document's top; the recorder still hears a step only while recording. Working alone, nothing is kept. Host.stepAuthor, topStep, mayMerge; forgetDocumentAuthors when a document closes. - Step.top: the entry on top after a step, so Undo can name whose step it takes back. The fingerprint moves. Nothing calls act yet, and owners report steps only while recording until the plan's step 3, so nothing changes for a person yet. Co-Authored-By: Claude Opus 5.5 --- app/App.zig | 2 + docs/PLUGINS.md | 15 ++- sdk/src/Author.zig | 38 ++++++ sdk/src/Host.zig | 300 +++++++++++++++++++++++++++++++++++++++++++- sdk/src/Step.zig | 5 + sdk/src/sdk.zig | 2 + sdk/src/version.zig | 2 +- 7 files changed, 357 insertions(+), 7 deletions(-) create mode 100644 sdk/src/Author.zig diff --git a/app/App.zig b/app/App.zig index c8f19265d..12d316d5c 100644 --- a/app/App.zig +++ b/app/App.zig @@ -932,6 +932,8 @@ pub fn closeDocumentResources(app: *App, doc: sdk.DocHandle) void { if (app.tracks) |*t| t.closed(doc.id); // Whoever held it is holding nothing now (`sdk.Claim`). app.host.releaseDocumentClaims(doc.id); + // Nor are its steps anyone's now (`Host.stepAuthor`). + app.host.forgetDocumentAuthors(doc.id); _ = doc.owner.closeDocument(doc); doc.owner.unregisterDocument(doc.id); } diff --git a/docs/PLUGINS.md b/docs/PLUGINS.md index a622b6f2d..3ad29197c 100644 --- a/docs/PLUGINS.md +++ b/docs/PLUGINS.md @@ -667,8 +667,9 @@ gets the same, and `DropZones.wheel`/`at`/`draw` give a plugin the same drop for document open just is not snapshotted, and seeks through it replay from the keyframe. `text` implements all three. - **Steps** (optional) — what a document's history records and replays (`sdk.Step`, - `plans/RECORDING_PLAN.md`). While `host.recording(doc.id)` says so, the owner reports each - change as it happens with `host.recordStep(doc.id, step)`: a new history entry (`added`), more of + `plans/RECORDING_PLAN.md`). The owner reports each change as it happens with + `host.recordStep(doc.id, step)`, and encodes its `effect` only while `host.recording(doc.id)` + says so: the host keeps who made every step (below), recorded or not. Each is a new history entry (`added`), more of the entry on top (`joined`, a run of typing), an entry taken back (`undone`, its effect the reverse) or put back (`redone`), and the history starting over (`restarted`, a reload). Every step's `effect` applies **forward**, in the owner's own encoding, numbered by the plugin's @@ -676,7 +677,15 @@ gets the same, and `DropZones.wheel`/`at`/`draw` give a plugin the same drop for when the owner knows. `applyStep(doc, effect, motion, ms)` lands one as a new history entry, shown being made over `ms` (0: at once), and refuses one that does not fit what the document holds, leaving it as it was. Nobody recording, nothing is encoded. `text` implements both: - each step is a transaction's edits, removed and inserted bytes, and the selection after. + each step is a transaction's edits, removed and inserted bytes, and the selection after. Fill + `top` (the entry on top once the step is done) so Undo can say whose step it takes back. +- **Authors** — who made each step (`sdk.Author`: the person, an agent, a tape; + `plans/SHARED_HISTORY_PLAN.md`). The host keeps it by step id; owners never store one. A plugin + changing a document on someone's behalf acts as them around the commands it runs + (`const outer = host.act(author); defer host.endAct(outer);`), and work it puts off stores + `host.acted()` and runs inside `host.actAgain(stored)`. An owner whose edits join a run (typing) + asks `host.mayMerge(doc.id, top_step)` and starts a new step when it says no, so one hand's + edits never fold into another's. `host.stepAuthor` and `host.topStep` say who made a step. - **Rendering** — `drawDocument(doc)` (the document's content, called through the document's surface wherever a region accepting `document` shows it), `infobarEntries(active_doc)` (icon + text chips Fizzy draws in the infobar; plugins do not draw into the bar). diff --git a/sdk/src/Author.zig b/sdk/src/Author.zig new file mode 100644 index 000000000..de3d51707 --- /dev/null +++ b/sdk/src/Author.zig @@ -0,0 +1,38 @@ +//! Who made a change to a document: the person, an agent, a tape, or nobody known +//! (`plans/SHARED_HISTORY_PLAN.md`). The host keeps a step's author by the step's id +//! (`Host.recordStep`); owners never store one. +const std = @import("std"); +const Author = @This(); + +kind: Kind, +/// Which one: the plugin acting, and its own id for the session or connection (the agent +/// plugin's connection, chat's session). Two agents both called "Claude" are two authors. +plugin: []const u8 = "", +session: u64 = 0, +/// As the person reads it: "Claude", "the tour". Display only, never identity. +label: []const u8 = "", + +pub const Kind = enum { + /// Whoever is at the keyboard and pointer. Their steps are never stored: a step with no + /// author kept is theirs. + person, + agent, + tape, + /// A step recorded where nobody acts: a missed site, never quietly the person's. + unattributed, +}; + +pub const person: Author = .{ .kind = .person }; +pub const unattributed: Author = .{ .kind = .unattributed }; + +/// The same author: kind, plugin and session. The label is not identity. +pub fn same(a: Author, b: Author) bool { + return a.kind == b.kind and a.session == b.session and std.mem.eql(u8, a.plugin, b.plugin); +} + +test "an author is its kind, plugin and session, not its label" { + const a: Author = .{ .kind = .agent, .plugin = "agent", .session = 1, .label = "Claude" }; + try std.testing.expect(a.same(.{ .kind = .agent, .plugin = "agent", .session = 1, .label = "An agent" })); + try std.testing.expect(!a.same(.{ .kind = .agent, .plugin = "agent", .session = 2, .label = "Claude" })); + try std.testing.expect(!a.same(person)); +} diff --git a/sdk/src/Host.zig b/sdk/src/Host.zig index 494ea31fc..de155910f 100644 --- a/sdk/src/Host.zig +++ b/sdk/src/Host.zig @@ -36,6 +36,7 @@ pub const accounts = @import("accounts.zig"); pub const Command = @import("Command.zig"); pub const Claim = @import("Claim.zig"); pub const Step = @import("Step.zig"); +pub const Author = @import("Author.zig"); pub const events = @import("events.zig"); /// Per-plugin opaque settings blobs pending a write: plugin id -> serialized zon text, or `null` @@ -291,6 +292,22 @@ claims: std.ArrayListUnmanaged(Claim) = .empty, /// Who records documents' steps (`recordStep`): the app's tracks. Null, nobody does, and owners /// encode nothing. recorder: ?Recorder = null, +/// Every author that has acted, interned by identity (`Author.same`); `Acting` and the step map +/// hold indices into it. Strings are copies, so a label outlives the plugin that gave it. +authors: std.ArrayListUnmanaged(Author) = .empty, +/// Who is acting now (`act`): an index into `authors`, and the run it belongs to. Null: nobody. +acting: Acting = .{}, +/// The frame's input author, set by the app where input is consumed (`setInputAuthor`): the +/// person's, or a tape's while one drives. Null outside those places. +input_author: ?u32 = null, +/// Runs started (`act`): one per outermost `act`. +runs: u64 = 0, +/// Who made each step not the person's, per document (`recordStep`). A document gets an entry only +/// once someone other than the person acts on it, so working alone this stays empty. +step_authors: std.AutoHashMapUnmanaged(u64, DocAuthors) = .empty, +/// Steps recorded where nobody acts (`Author.Kind.unattributed`): a missed site. The integration +/// tests assert it stays 0. +unattributed_steps: u64 = 0, next_claim_id: u32 = 1, /// Pluggable language/format support (syntax highlighting, preview panes). language_support: std.ArrayListUnmanaged(LanguageSupport) = .empty, @@ -333,6 +350,14 @@ pub fn deinit(self: *Host) void { for (self.claims.items) |c| self.allocator.free(c.label); self.claims.deinit(self.allocator); + for (self.authors.items) |a| { + self.allocator.free(a.plugin); + self.allocator.free(a.label); + } + self.authors.deinit(self.allocator); + var docs = self.step_authors.valueIterator(); + while (docs.next()) |d| d.steps.deinit(self.allocator); + self.step_authors.deinit(self.allocator); self.plugins.deinit(self.allocator); self.services.deinit(self.allocator); self.selections.deinit(self.allocator); @@ -1586,10 +1611,180 @@ pub fn recording(self: *const Host, doc_id: u64) bool { } /// A step of `doc_id`'s history, from its **owner**, as it happens (`Step`): each entry added or -/// joined, undone or redone, and the history starting over. Only while `recording(doc_id)`. +/// joined, undone or redone, and the history starting over. Every step, recorded or not: the host +/// keeps who made it (`stepAuthor`) and the document's top (`topStep`). An owner encodes `effect` +/// only while `recording(doc_id)`; the recorder hears the step only then. pub fn recordStep(self: *Host, doc_id: u64, step: Step) void { - const r = self.recorder orelse return; - r.vtable.step(r.ctx, doc_id, step); + self.noteAuthor(doc_id, step); + if (self.recording(doc_id)) { + const r = self.recorder.?; + r.vtable.step(r.ctx, doc_id, step); + } +} + +// ---- authors (`plans/SHARED_HISTORY_PLAN.md`) ------------------------------------------------ + +/// Who is acting, to restore with `endAct` or to store and act as again later (`actAgain`): an +/// index into the host's authors, and the run. +pub const Acting = struct { + author: ?u32 = null, + run: u64 = 0, +}; + +/// One document's authors: who made each step that is not the person's, by step id, and the step +/// on top. +pub const DocAuthors = struct { + steps: std.AutoHashMapUnmanaged(u64, StepAuthor) = .empty, + top: u64 = 0, +}; + +pub const StepAuthor = struct { + /// Index into the host's authors. + author: u32, + /// The run it was made in (`act`): one MCP call of an agent's, one op of a tape's. + run: u64, +}; + +/// Act as `author` until `endAct` with what this returns: the steps recorded meanwhile are +/// theirs. The outermost `act` starts a run; one inside it keeps it. Innermost wins. UI thread +/// only. +pub fn act(self: *Host, who: Author) Acting { + const outer = self.acting; + const index = self.intern(who); + self.acting = .{ + .author = index, + .run = if (outer.author == null) run: { + self.runs += 1; + break :run self.runs; + } else outer.run, + }; + return outer; +} + +/// Act as before: `stored` is what `acted` said when a call was put off (pixi's call waiting out +/// the person's stroke), so its steps are still that author's, in that run. +pub fn actAgain(self: *Host, stored: Acting) Acting { + const outer = self.acting; + self.acting = stored; + return outer; +} + +/// Who is acting now, to store with work done later (`actAgain`). +pub fn acted(self: *const Host) Acting { + return self.acting; +} + +pub fn endAct(self: *Host, outer: Acting) void { + self.acting = outer; +} + +/// Who is acting now: the innermost `act`, else the frame's input author where input is being +/// consumed, else nobody known. +pub fn author(self: *const Host) Author { + const index = self.acting.author orelse self.input_author orelse return .unattributed; + return self.authors.items[index]; +} + +/// The frame's input author (the app's to set, around where input is consumed): the person's, or +/// a tape's while one drives. Null where no input is consumed. +pub fn setInputAuthor(self: *Host, who: ?Author) void { + self.input_author = if (who) |a| self.intern(a) else null; +} + +/// Who made step `id` of `doc_id`: the person when none other is kept. +pub fn stepAuthor(self: *const Host, doc_id: u64, id: u64) Author { + const doc = self.step_authors.getPtr(doc_id) orelse return .person; + const s = doc.steps.get(id) orelse return .person; + return self.authors.items[s.author]; +} + +pub const TopStep = struct { id: u64, author: Author }; + +/// The step on top of `doc_id`'s history and who made it, when the host knows it: once someone +/// other than the person has acted on the document, from the steps its owner reports. +pub fn topStep(self: *const Host, doc_id: u64) ?TopStep { + const doc = self.step_authors.getPtr(doc_id) orelse return null; + if (doc.top == 0) return null; + return .{ .id = doc.top, .author = self.stepAuthor(doc_id, doc.top) }; +} + +/// Whether the acting author may fold an edit into step `id` of `doc_id`: when `id` is theirs (a +/// step with no author kept is the person's). False ends a run of typing: the owner starts a new +/// step, so an agent's keystrokes never join the person's. +pub fn mayMerge(self: *const Host, doc_id: u64, id: u64) bool { + const who = self.author(); + const made = self.stepAuthor(doc_id, id); + // Nobody known acting is a missed site (`unattributed_steps`); it merges as the person would. + if (who.kind == .unattributed) return made.kind == .person; + return who.same(made); +} + +/// Forget `doc_id`'s authors: the document closed. +pub fn forgetDocumentAuthors(self: *Host, doc_id: u64) void { + var entry = self.step_authors.fetchRemove(doc_id) orelse return; + entry.value.steps.deinit(self.allocator); +} + +fn intern(self: *Host, who: Author) u32 { + for (self.authors.items, 0..) |*a, i| { + if (!a.same(who)) continue; + // The label may come later (an agent's name, known once its client says hello). + if (who.label.len > 0 and !std.mem.eql(u8, a.label, who.label)) { + if (self.allocator.dupe(u8, who.label)) |label| { + self.allocator.free(a.label); + a.label = label; + } else |_| {} + } + return @intCast(i); + } + const plugin = self.allocator.dupe(u8, who.plugin) catch return self.internFailed(); + const label = self.allocator.dupe(u8, who.label) catch { + self.allocator.free(plugin); + return self.internFailed(); + }; + self.authors.append(self.allocator, .{ .kind = who.kind, .plugin = plugin, .session = who.session, .label = label }) catch { + self.allocator.free(plugin); + self.allocator.free(label); + return self.internFailed(); + }; + return @intCast(self.authors.items.len - 1); +} + +/// Out of memory interning: nobody known, rather than wrongly the person. Its strings are static, +/// so `deinit` frees nothing it did not allocate: an empty slice frees as a no-op. +fn internFailed(self: *Host) u32 { + for (self.authors.items, 0..) |a, i| if (a.kind == .unattributed and a.plugin.len == 0) return @intCast(i); + self.authors.append(self.allocator, .unattributed) catch {}; + return @intCast(self.authors.items.len -| 1); +} + +/// Keep who made `step`, and `doc_id`'s top (`recordStep`). +fn noteAuthor(self: *Host, doc_id: u64, step: Step) void { + const who = self.author(); + if (who.kind == .unattributed and step.kind != .restarted) self.unattributed_steps += 1; + const theirs = who.kind != .person; + // Working alone, nothing is kept: a document gets an entry the first time someone else acts. + const doc = if (self.step_authors.getPtr(doc_id)) |d| d else if (theirs) blk: { + const gop = self.step_authors.getOrPut(self.allocator, doc_id) catch return; + gop.value_ptr.* = .{}; + break :blk gop.value_ptr; + } else return; + switch (step.kind) { + .restarted => { + doc.steps.clearRetainingCapacity(); + doc.top = 0; + }, + .added => { + if (theirs) { + const index = self.acting.author orelse self.input_author orelse self.internFailed(); + doc.steps.put(self.allocator, step.id, .{ .author = index, .run = self.acting.run }) catch {}; + } + doc.top = if (step.top != 0) step.top else step.id; + }, + // A step joined keeps the author it began with (`mayMerge` decided it could join). + .joined => doc.top = if (step.top != 0) step.top else step.id, + .undone, .redone => doc.top = step.top, + } } // ---- activity -------------------------------------------------------------------------------- @@ -2860,3 +3055,102 @@ test "a surface's title can change: the Host keeps the copy, and frees it with t host.unregisterPlugin(&plugin); try testing.expectEqual(@as(u32, 0), host.surface_titles.count()); } + +test "a step is its author's, the person's when nobody else acts, and the top follows the owner" { + var host: Host = .init(std.testing.allocator); + defer host.deinit(); + const doc: u64 = 7; + const claude: Author = .{ .kind = .agent, .plugin = "agent", .session = 1, .label = "Claude" }; + + // Working alone: nothing kept. + host.setInputAuthor(.person); + host.recordStep(doc, .{ .kind = .added, .id = 1 }); + try std.testing.expectEqual(@as(usize, 0), host.step_authors.count()); + try std.testing.expect(host.topStep(doc) == null); + + // An agent's call: its step is its own, in one run, and on top. + const outer = host.act(claude); + host.recordStep(doc, .{ .kind = .added, .id = 2 }); + host.recordStep(doc, .{ .kind = .added, .id = 3 }); + host.endAct(outer); + try std.testing.expect(host.stepAuthor(doc, 2).same(claude)); + try std.testing.expectEqualStrings("Claude", host.stepAuthor(doc, 3).label); + try std.testing.expectEqual(host.step_authors.getPtr(doc).?.steps.get(2).?.run, host.step_authors.getPtr(doc).?.steps.get(3).?.run); + try std.testing.expectEqual(@as(u64, 3), host.topStep(doc).?.id); + + // The person's step after it is theirs, and the top. + host.recordStep(doc, .{ .kind = .added, .id = 4 }); + try std.testing.expectEqual(Author.Kind.person, host.stepAuthor(doc, 4).kind); + try std.testing.expectEqual(Author.Kind.person, host.topStep(doc).?.author.kind); + + // The person undoes theirs: the agent's is on top again, as the owner says. + host.recordStep(doc, .{ .kind = .undone, .id = 4, .top = 3 }); + try std.testing.expect(host.topStep(doc).?.author.same(claude)); + + // A history started over knows no authors. + host.recordStep(doc, .{ .kind = .restarted, .id = 0 }); + try std.testing.expectEqual(Author.Kind.person, host.stepAuthor(doc, 2).kind); + try std.testing.expect(host.topStep(doc) == null); + + host.forgetDocumentAuthors(doc); + try std.testing.expectEqual(@as(usize, 0), host.step_authors.count()); + try std.testing.expectEqual(@as(u64, 0), host.unattributed_steps); +} + +test "acting: innermost wins, a run is the outermost act, and work put off acts again" { + var host: Host = .init(std.testing.allocator); + defer host.deinit(); + const claude: Author = .{ .kind = .agent, .plugin = "agent", .session = 1 }; + const tour: Author = .{ .kind = .tape, .plugin = "fizzy", .session = 9, .label = "the tour" }; + + try std.testing.expectEqual(Author.Kind.unattributed, host.author().kind); + host.setInputAuthor(.person); + try std.testing.expectEqual(Author.Kind.person, host.author().kind); + + const a = host.act(claude); + const run = host.acted().run; + const b = host.act(tour); + try std.testing.expect(host.author().same(tour)); + try std.testing.expectEqual(run, host.acted().run); + host.endAct(b); + try std.testing.expect(host.author().same(claude)); + const stored = host.acted(); + host.endAct(a); + try std.testing.expectEqual(Author.Kind.person, host.author().kind); + + // A second call is a second run; the stored one acts again as itself. + const c = host.act(claude); + try std.testing.expect(host.acted().run != run); + host.endAct(c); + const d = host.actAgain(stored); + try std.testing.expectEqual(run, host.acted().run); + host.endAct(d); + + // The same author interned once; a label given later is kept. + _ = host.act(.{ .kind = .agent, .plugin = "agent", .session = 1, .label = "Claude" }); + try std.testing.expectEqual(@as(usize, 3), host.authors.items.len); + try std.testing.expectEqualStrings("Claude", host.author().label); +} + +test "mayMerge: only the author's own step, so an agent never joins the person's run of typing" { + var host: Host = .init(std.testing.allocator); + defer host.deinit(); + const doc: u64 = 3; + const claude: Author = .{ .kind = .agent, .plugin = "agent", .session = 1 }; + host.setInputAuthor(.person); + host.recordStep(doc, .{ .kind = .added, .id = 1 }); + try std.testing.expect(host.mayMerge(doc, 1)); + + const outer = host.act(claude); + try std.testing.expect(!host.mayMerge(doc, 1)); + host.recordStep(doc, .{ .kind = .added, .id = 2 }); + try std.testing.expect(host.mayMerge(doc, 2)); + host.endAct(outer); + try std.testing.expect(!host.mayMerge(doc, 2)); + + // Nobody known acting is counted, and merges as the person would. + host.setInputAuthor(null); + try std.testing.expect(host.mayMerge(doc, 1)); + host.recordStep(doc, .{ .kind = .added, .id = 3 }); + try std.testing.expectEqual(@as(u64, 1), host.unattributed_steps); +} diff --git a/sdk/src/Step.zig b/sdk/src/Step.zig index 76a23540e..5b0a7b7cd 100644 --- a/sdk/src/Step.zig +++ b/sdk/src/Step.zig @@ -19,6 +19,11 @@ effect: []const u8 = "", motion: []const u8 = "", /// How long it took to make, as the owner saw it (a stroke from press to release). 0: at once. ms: u32 = 0, +/// The entry on top once this step is done (the last one applied), 0 when none is. The host keeps +/// each document's top from it, so Undo's label can name whose step it would take back without +/// asking for the line. An owner that does not fill it leaves it 0: the host then takes an +/// `added` or `joined` step's own `id` as the top, and knows no top after an undo or redo. +top: u64 = 0, pub const Kind = enum { /// A new entry on the history. diff --git a/sdk/src/sdk.zig b/sdk/src/sdk.zig index b4afc7dc1..71f97f854 100644 --- a/sdk/src/sdk.zig +++ b/sdk/src/sdk.zig @@ -73,6 +73,8 @@ pub const Command = @import("Command.zig"); pub const Claim = @import("Claim.zig"); /// One step of a document's history, recorded (`Host.recordStep`) and applied (`Plugin.applyStep`). pub const Step = @import("Step.zig"); +/// Who made a change to a document (`Host.act`, `Host.stepAuthor`). +pub const Author = @import("Author.zig"); /// Events: a plugin announces, any plugin listens (`Host.emit`, `Host.subscribe`). pub const events = @import("events.zig"); pub const menu = @import("menu.zig"); diff --git a/sdk/src/version.zig b/sdk/src/version.zig index 8bc456baa..ad46c1417 100644 --- a/sdk/src/version.zig +++ b/sdk/src/version.zig @@ -75,7 +75,7 @@ pub const sdk_version = @import("sdk_version").sdk_version; /// why it is a single target/mode-invariant literal rather than a per-target table. Update this /// value from the `@compileError` it triggers whenever it changes; leave `sdk_version` to the /// next SDK release. -pub const recorded_sdk_shape_fingerprint: u64 = 0x6fc6a45bf3a13b00; +pub const recorded_sdk_shape_fingerprint: u64 = 0x78f2a3cc371828ef; comptime { if (dylib.sdk_shape_fingerprint != recorded_sdk_shape_fingerprint) { From 65fcfc046e97150939dc019936724bfb9bfc0ff2 Mon Sep 17 00:00:00 2001 From: foxnne Date: Fri, 16 Oct 2026 16:46:25 -0500 Subject: [PATCH 2/2] app: whose frame it is, and bubbles that say who MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The app half of plans/SHARED_HISTORY_PLAN.md's step 2, on the host's authors (the PR below): - The frame's input author: Editor.tick sets it around where input is consumed (the layout's draw, keybinds, overlays, the palette) and clears it around plugins' frame hooks: the person's, or a tape's while one drives (Demo.author), the drivers having already stopped for real input. - A tape's commands act as the tape (Demo's stage command), a live tape as whoever it plays for. - automation.Api.playAs (service version 6): play a live tape for an author, so an agent's pointer tape is the agent's. play alone leaves the tape its own author. - An agent's edit says whose in the activity bubble ("Claude · Draw"). The person's, and a demo's narrating itself, stay plain. - The whole-editor test asserts the tour adds no unattributed step. Co-Authored-By: Claude Opus 5.5 --- app/automation/Service.zig | 20 ++++++++++++- sdk/src/Host.zig | 25 +++++++++++++++- sdk/src/services/automation.zig | 12 +++++++- src/editor/Demo.zig | 51 ++++++++++++++++++++++++++++++++- src/editor/Editor.zig | 7 +++++ tests/integration.zig | 6 ++++ 6 files changed, 117 insertions(+), 4 deletions(-) diff --git a/app/automation/Service.zig b/app/automation/Service.zig index a301e0ad6..77c2eaf1d 100644 --- a/app/automation/Service.zig +++ b/app/automation/Service.zig @@ -27,6 +27,8 @@ gpa: std.mem.Allocator, driver: *LiveDriver, /// Something else that drives the app, refusing a tape while it does — fizzy's demos. other: ?Other = null, +/// Told who each live tape plays for (`OnAuthor`). Null: nobody listens, and authors are dropped. +on_author: ?OnAuthor = null, /// What `snapshot` takes, framed by the app (`Snapshot.beginFrame` / `endFrame`). Null: no /// snapshots, and `snapshotText` is always null. snapshots: ?*Snapshot = null, @@ -51,6 +53,13 @@ pub const Other = struct { driving: *const fn (ctx: *anyopaque) bool, }; +/// Who the next live tape plays for (`Api.playAs`; null: itself), told to the app that runs its +/// commands and frames: fizzy's demo, which acts as them (`Host.act`). +pub const OnAuthor = struct { + ctx: *anyopaque, + set: *const fn (ctx: *anyopaque, author: ?sdk.Author) void, +}; + const Past = struct { ticket: Api.Ticket, outcome: Api.Outcome, @@ -63,6 +72,7 @@ pub fn bind(self: *Service) void { const vtable: Api.VTable = .{ .play = play, + .playAs = playAs, .stop = stop, .outcome = outcome, .settled = settled, @@ -82,13 +92,21 @@ fn othersDriving(self: *Service) bool { } fn play(ctx: *anyopaque, bytes: []const u8) Api.Play { - const self = from(ctx); + return playFor(from(ctx), bytes, null); +} + +fn playAs(ctx: *anyopaque, bytes: []const u8, author: sdk.Author) Api.Play { + return playFor(from(ctx), bytes, author); +} + +fn playFor(self: *Service, bytes: []const u8, author: ?sdk.Author) Api.Play { if (self.driver.playing() or self.othersDriving()) return .busy; var check = Input.check; check.live = true; const owned = Tape.load(self.gpa, bytes, check) catch |err| return .{ .invalid = @errorName(err) }; // Read before the driver starts over and forgets it. const last = self.driverOutcome(); + if (self.on_author) |o| o.set(o.ctx, author); self.driver.play(owned) catch |err| return switch (err) { error.Busy => .busy, else => .{ .invalid = @errorName(err) }, diff --git a/sdk/src/Host.zig b/sdk/src/Host.zig index de155910f..d881e227f 100644 --- a/sdk/src/Host.zig +++ b/sdk/src/Host.zig @@ -1809,7 +1809,14 @@ pub fn documentEdited(self: *Host, doc_id: u64) void { if (self.activity.getLastOrNull()) |last| { if (self.activity_run == self.command_runs and last.doc_id == doc_id) return; } - const title = self.allocator.dupe(u8, std.mem.trimEnd(u8, c.title, "…")) catch return; + const what = std.mem.trimEnd(u8, c.title, "…"); + // An agent's edit says whose ("Claude · Draw"); the person's own, and a demo narrating itself, + // stay plain. + const who = self.author(); + const title = (if (who.kind == .agent and who.label.len > 0) + std.fmt.allocPrint(self.allocator, "{s} · {s}", .{ who.label, what }) + else + self.allocator.dupe(u8, what)) catch return; if (self.activity.items.len == activity_cap) { self.allocator.free(self.activity.items[0].title); _ = self.activity.orderedRemove(0); @@ -3154,3 +3161,19 @@ test "mayMerge: only the author's own step, so an agent never joins the person's host.recordStep(doc, .{ .kind = .added, .id = 3 }); try std.testing.expectEqual(@as(u64, 1), host.unattributed_steps); } + +test "an agent's edit says whose in the activity; the person's stays plain" { + var host: Host = .init(std.testing.allocator); + defer host.deinit(); + const draw: Command = .{ .id = "pixi.draw", .title = "Draw" }; + host.running_command = &draw; + host.setInputAuthor(.person); + host.documentEdited(1); + try std.testing.expectEqualStrings("Draw", host.activity.items[0].title); + + host.command_runs += 1; + const outer = host.act(.{ .kind = .agent, .plugin = "agent", .session = 1, .label = "Claude" }); + host.documentEdited(1); + host.endAct(outer); + try std.testing.expectEqualStrings("Claude · Draw", host.activity.items[1].title); +} diff --git a/sdk/src/services/automation.zig b/sdk/src/services/automation.zig index 0cdf45c02..f469a8caa 100644 --- a/sdk/src/services/automation.zig +++ b/sdk/src/services/automation.zig @@ -48,10 +48,12 @@ //! //! Optional, like every service: an app with nothing to play tapes on registers none. +const Author = @import("../Author.zig"); + pub const Api = struct { /// Bump whenever this struct's layout changes: `getServiceTyped` refuses a provider whose /// version differs rather than reinterpreting one shape as another across `dlopen`. - pub const service_version: u32 = 5; + pub const service_version: u32 = 6; pub const service_name = "automation"; ctx: *anyopaque, @@ -89,6 +91,8 @@ pub const Api = struct { pub const VTable = struct { play: *const fn (ctx: *anyopaque, bytes: []const u8) Play, + /// `play`, the tape acting for `author` (`Host.act`): an agent's pointer tape is the agent's. + playAs: *const fn (ctx: *anyopaque, bytes: []const u8, author: Author) Play, stop: *const fn (ctx: *anyopaque, ticket: Ticket) void, outcome: *const fn (ctx: *anyopaque, ticket: Ticket) Outcome, settled: *const fn (ctx: *anyopaque) bool, @@ -118,6 +122,12 @@ pub const Api = struct { return self.vtable.play(self.ctx, bytes); } + /// `play`, with what the tape changes made by `author` (an agent playing pointer input on the + /// person's behalf): `play` alone leaves the tape its own author. + pub fn playAs(self: Api, bytes: []const u8, author: Author) Play { + return self.vtable.playAs(self.ctx, bytes, author); + } + /// Stop the tape `ticket` names where it is. Nothing when that tape is not the one playing: /// a caller can stop its own tape, never someone else's. pub fn stop(self: Api, ticket: Ticket) void { diff --git a/src/editor/Demo.zig b/src/editor/Demo.zig index 1ca73df43..19618c326 100644 --- a/src/editor/Demo.zig +++ b/src/editor/Demo.zig @@ -73,6 +73,9 @@ quitting: bool = false, arrange: ?automation.Tape.Keyframe.Layout = null, /// Every plugin's settings as the demo found them, as ZON, to give back at the end. settings_saved: std.ArrayListUnmanaged(SavedSettings) = .empty, +/// Who a live tape plays for (`automation.Api.playAs`): an agent's pointer tape is the agent's. +/// Strings owned here. Null: the live tape is its own author. +live_author: ?sdk.Author = null, const SavedSettings = struct { owner: []u8, @@ -118,7 +121,7 @@ pub fn attach(self: *Demo, editor: *Editor) void { .editor = editor, .player = .init(gpa, stage), .live = .init(stage), - .service = .{ .gpa = gpa, .driver = &self.live, .other = .{ .ctx = self, .driving = demoLoaded }, .snapshots = &self.snapshot, .captures = &self.picture }, + .service = .{ .gpa = gpa, .driver = &self.live, .other = .{ .ctx = self, .driving = demoLoaded }, .on_author = .{ .ctx = self, .set = liveAuthorSet }, .snapshots = &self.snapshot, .captures = &self.picture }, .snapshot = .init(gpa), .picture = .init(gpa), }; @@ -133,6 +136,7 @@ fn demoLoaded(ctx: *anyopaque) bool { } pub fn deinit(self: *Demo) void { + self.setLiveAuthor(null); self.quitting = true; self.snapshot.deinit(); self.picture.deinit(); @@ -354,6 +358,48 @@ const stage_vtable: automation.Stage.VTable = .{ .fingerprint = fingerprint, }; +/// A live tape played by nobody in particular, or a demo with no name. +const tape_author: sdk.Author = .{ .kind = .tape, .plugin = "fizzy", .label = "a tape" }; + +/// Whose frame this is when a tape drives it: the live tape's author (`live_author`, else the +/// tape itself), or the demo being played or sought through. Null when no tape drives: a real +/// press or key stops either before the frame's input is consumed, so the frame is the person's. +pub fn author(self: *const Demo) ?sdk.Author { + if (self.live.playing()) return self.live_author orelse tape_author; + switch (self.player.state) { + .playing, .seeking => {}, + else => return null, + } + const tape = (self.player.owned orelse return tape_author).tape; + return .{ + .kind = .tape, + .plugin = "fizzy", + .session = std.hash.Wyhash.hash(0, tape.name), + .label = if (tape.title.len > 0) tape.title else tape.name, + }; +} + +fn liveAuthorSet(ctx: *anyopaque, who: ?sdk.Author) void { + from(ctx).setLiveAuthor(who); +} + +/// Play the next live tape for `who` (`automation.Api.playAs`); null, for no one in particular. +pub fn setLiveAuthor(self: *Demo, who: ?sdk.Author) void { + const gpa = self.editor.app.gpa; + if (self.live_author) |a| { + gpa.free(a.plugin); + gpa.free(a.label); + } + self.live_author = null; + const a = who orelse return; + const plugin = gpa.dupe(u8, a.plugin) catch return; + const label = gpa.dupe(u8, a.label) catch { + gpa.free(plugin); + return; + }; + self.live_author = .{ .kind = a.kind, .plugin = plugin, .session = a.session, .label = label }; +} + fn from(ctx: *anyopaque) *Demo { return @ptrCast(@alignCast(ctx)); } @@ -628,6 +674,9 @@ fn idle(ctx: *anyopaque) bool { /// palette does once they are given; what it returns is not the demo's business. fn command(ctx: *anyopaque, id: []const u8, args: []const u8) void { const host = &from(ctx).editor.app.host; + // A tape's command is the tape's (or, for a live tape played for someone, theirs). + const outer = host.act(from(ctx).author() orelse tape_author); + defer host.endAct(outer); if (args.len == 0) { host.runCommand(id) catch |err| dvui.log.warn("demo: command {s} failed: {t}", .{ id, err }); return; diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig index e5fba8cdd..05ddd63ef 100644 --- a/src/editor/Editor.zig +++ b/src/editor/Editor.zig @@ -4315,6 +4315,10 @@ pub fn tick(editor: *Editor) !dvui.App.Result { // layout needed these five calls verbatim, and getting one wrong is a bug an app // author has no way to diagnose. A shape declares regions; it does not run the // frame. + // The frame's input is consumed from here to the palette: whatever it changes is the + // person's, or a tape's while one drives (`Host.setInputAuthor`). Plugins' frame hooks + // outside it act for themselves, or are nobody's. + editor.app.host.setInputAuthor(editor.demo.author() orelse .person); var layout_root = dvui.box(@src(), .{ .dir = .vertical }, .{ .expand = .both, .background = false }); for (editor.app.host.plugins.items) |plugin| plugin.tickActiveDocument(layout_root.data().id); editor.flushQueuedNativeMenuActions(); @@ -4367,6 +4371,7 @@ pub fn tick(editor: *Editor) !dvui.App.Result { .{layout.depth}, ); + editor.app.host.setInputAuthor(null); for (editor.app.host.plugins.items) |plugin| plugin.endFrame(); layout_root.deinit(); @@ -4374,6 +4379,8 @@ pub fn tick(editor: *Editor) !dvui.App.Result { } { // Plugin keybinds + per-frame overlays (e.g. pixel-art's radial menu) + editor.app.host.setInputAuthor(editor.demo.author() orelse .person); + defer editor.app.host.setInputAuthor(null); // While the palette owns the keyboard, plugin ticks must not run — otherwise a // plugin's `matchBind` (e.g. pixi Export on the same chord as Quick Open) steals // focus before the palette entry can claim it. diff --git a/tests/integration.zig b/tests/integration.zig index d6ea92919..c03cd8ba0 100644 --- a/tests/integration.zig +++ b/tests/integration.zig @@ -7223,12 +7223,18 @@ test "headless: the whole editor comes up, opens a file, plays the tour and goes const lookback = fizzy.core.profile.lookback() orelse return error.TestUnexpectedResult; lookback.budget_ns = 0; defer lookback.budget_ns = std.time.ns_per_s / 60; + const unattributed_before = editor.app.host.unattributed_steps; try editor.demo.play("tour"); var frames: usize = 0; while (editor.demo.player.state != .ended and frames < 5000) : (frames += 1) { _ = try dvui.testing.step(headlessFrame); } try std.testing.expectEqual(app.automation.Player.State.ended, editor.demo.player.state); + // Every step the tour made has an author: its own where it drives the frame, or its commands' + // (`Host.act` in the demo's stage). A step nobody acted for is a missed site + // (`plans/SHARED_HISTORY_PLAN.md`). Owners report every step once that plan's step 3 lands; + // until then this guards the steps reported while recording. + try std.testing.expectEqual(unattributed_before, editor.app.host.unattributed_steps); { var out: std.Io.Writer.Allocating = .init(std.testing.allocator); defer out.deinit();