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 58e265248..19b47b377 100644 --- a/src/editor/Editor.zig +++ b/src/editor/Editor.zig @@ -4321,6 +4321,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(); @@ -4373,6 +4377,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(); @@ -4380,6 +4385,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 e86cf045d..f99366bfc 100644 --- a/tests/integration.zig +++ b/tests/integration.zig @@ -7226,12 +7226,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();