Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
20 changes: 19 additions & 1 deletion app/automation/Service.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -63,6 +72,7 @@ pub fn bind(self: *Service) void {

const vtable: Api.VTable = .{
.play = play,
.playAs = playAs,
.stop = stop,
.outcome = outcome,
.settled = settled,
Expand All @@ -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) },
Expand Down
25 changes: 24 additions & 1 deletion sdk/src/Host.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
}
12 changes: 11 additions & 1 deletion sdk/src/services/automation.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down
51 changes: 50 additions & 1 deletion src/editor/Demo.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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),
};
Expand All @@ -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();
Expand Down Expand Up @@ -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));
}
Expand Down Expand Up @@ -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;
Expand Down
7 changes: 7 additions & 0 deletions src/editor/Editor.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -4373,13 +4377,16 @@ 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();

if (try shell_result != .ok) return try shell_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.
Expand Down
6 changes: 6 additions & 0 deletions tests/integration.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading