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
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,13 @@
# ghostty
Example plugin for fizzy adding a ghostty terminal to the bottom panel

## `terminal.run`

Other plugins run a program in a terminal the person types into through the internal command
`terminal.run` (the chat's terminal sign-in, `claude /login`, is one). Its arguments, as ZON:
`.{ .title, .argv, .env = .{ .{ .name, .value } }, .cwd, .done, .tag }`. The program runs in a
pane of its own in the bottom panel, in `cwd` (empty: the project folder), over fizzy's
environment with the login shell's `PATH` plus `env`. When it exits, `terminal.run` calls the
command `done` with `.{ .tag, .code }`; the pane stays, showing the exit code, until the person
closes it. A caller checks `host.hasCommand("terminal.run")` before offering anything that needs
it. The contract is `src/launch.zig`.
9 changes: 9 additions & 0 deletions build.zig
Original file line number Diff line number Diff line change
Expand Up @@ -42,4 +42,13 @@ pub fn build(b: *std.Build) void {
plugin.module.link_libc = true;

fizzy.plugin.install(b, plugin.lib, .{});

// The parts that need no window: the `terminal.run` contract and the PTY running a command.
const tests = b.addTest(.{ .root_module = b.createModule(.{
.root_source_file = b.path("src/tests.zig"),
.target = target,
.optimize = optimize,
.link_libc = true,
}) });
b.step("test", "Run the unit tests").dependOn(&b.addRunArtifact(tests).step);
}
89 changes: 82 additions & 7 deletions plugin.zig
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
//! Ghostty terminal plugin — contributes a single bottom-panel tab ("Ghostty") that embeds
//! a libghostty-vt terminal beside pixi's "Sprites" tab. The shell shows a tab strip
//! Ghostty terminal plugin — contributes a bottom-panel tab ("Ghostty") that embeds a
//! libghostty-vt terminal beside pixi's "Sprites" tab. The shell shows a tab strip
//! automatically when more than one plugin contributes a bottom view, so this is purely
//! additive — no shell or pixi changes.
//!
//! It also offers `terminal.run` (`src/launch.zig`): another plugin runs a program in a pane of
//! its own here and hears its exit code, without either importing the other (`src/Run.zig`).
//!
//! This is a "shell"/utility plugin: it owns no documents, so it implements none of the
//! document vtable hooks (only `deinit`). See fizzy `docs/PLUGINS.md`.
const sdk = @import("fizzy_sdk");
Expand All @@ -11,6 +14,8 @@ const dvui = @import("dvui");
const State = @import("src/State.zig");
const render = @import("src/render.zig");
const input = @import("src/input.zig");
const launch = @import("src/launch.zig");
const Run = @import("src/Run.zig");

/// Injected at build time from `plugin.zig.zon` — required by fizzy's generated dylib root,
/// which reaches its own copy of this plugin's identity through this export rather than
Expand Down Expand Up @@ -60,6 +65,9 @@ comptime {

/// The plugin's own singleton state — a variable it owns. The SDK holds gpa/host.
var plugin_state: State = .{};
/// The `terminal.run` panes, open until the person closes them.
var runs: std.ArrayListUnmanaged(*Run) = .empty;
var next_run: u32 = 0;

/// Entry point the host calls once at startup (static) or after dlopen (dynamic). Wire state,
/// register the plugin, then add the bottom-panel Ghostty tab.
Expand All @@ -77,6 +85,50 @@ pub fn register(host: *sdk.Host) !void {
// Stay visible even with no active document, like pixi's Sprites tab.
.persistent = true,
});
try host.registerCommand(.{
.id = launch.command_id,
.owner = &plugin,
.title = "Terminal: Run a Program",
// Another plugin's call (`launch.Request`), not something a person runs by hand.
.visibility = .internal,
.runWith = runCommand,
});
}

/// `terminal.run`: open a pane running the request's program, and show it.
fn runCommand(_: *anyopaque, c: *sdk.Command.Call) anyerror!void {
const req = launch.parse(c.arena, c.args) catch |err| {
c.bad_args = true;
return c.fail("expected {s}'s arguments (title, argv, env, cwd, done, tag): {t}", .{ launch.command_id, err });
};
const gpa = sdk.allocator();
const host = sdk.host();
try runs.ensureUnusedCapacity(gpa, 1);
const id = try std.fmt.allocPrint(gpa, "ghostty.run.{d}", .{next_run});
const run = Run.create(gpa, req, id) catch |err| {
gpa.free(id);
return c.fail("could not open a terminal for {s}: {t}", .{ req.argv[0], err });
};
errdefer run.destroy(gpa);
try host.registerSurface(.{
.id = run.id,
.owner = &plugin,
.title = run.title,
.keywords = sdk.keywords.ide.panel,
.ctx = run,
.draw = Run.draw,
.persistent = true,
});
if (!host.revealSurface(run.id)) {
host.unregisterSurface(run.id);
return c.fail("this app has no place to show a terminal", .{});
}
run.begin() catch |err| {
host.unregisterSurface(run.id);
return c.fail("could not start {s}: {t}", .{ req.argv[0], err });
};
next_run += 1;
runs.appendAssumeCapacity(run);
}

fn drawSurface(ctx: ?*anyopaque) anyerror!dvui.App.Result {
Expand All @@ -90,12 +142,35 @@ pub fn pluginPtr() *sdk.Plugin {
}

fn deinit(_: *anyopaque) void {
plugin_state.deinit(sdk.allocator());
const gpa = sdk.allocator();
for (runs.items) |run| {
// A program still running is hung up on; its caller hears so, if it is still loaded.
run.tell(Run.hung_up);
sdk.host().unregisterSurface(run.id);
run.destroy(gpa);
}
runs.deinit(gpa);
plugin_state.deinit(gpa);
}

fn endFrame(_: *anyopaque) void {
// Focus handling only while the terminal is the panel's chosen surface.
const chosen = sdk.host().selectionFor(sdk.keywords.ide.panel) orelse return;
if (!std.mem.eql(u8, chosen, bottom_terminal)) return;
input.handleLateFocus(&plugin_state);
const host = sdk.host();
var i: usize = 0;
while (i < runs.items.len) {
const run = runs.items[i];
run.poll();
if (!run.closing) {
i += 1;
continue;
}
run.tell(Run.hung_up);
host.unregisterSurface(run.id);
_ = runs.orderedRemove(i);
run.destroy(sdk.allocator());
}

// Focus handling only for the terminal that is the panel's chosen surface.
const chosen = host.selectionFor(sdk.keywords.ide.panel) orelse return;
if (std.mem.eql(u8, chosen, bottom_terminal)) return input.handleLateFocus(&plugin_state);
for (runs.items) |run| if (std.mem.eql(u8, chosen, run.id)) return input.handleLateFocus(&run.state);
}
14 changes: 13 additions & 1 deletion src/Pty.zig
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
//! A pseudoterminal running the user's shell, with a background reader thread.
//! A pseudoterminal running the user's shell, or a program another plugin asked for, with a
//! background reader thread.
//!
//! Unix (macOS/Linux): `forkpty` + reader thread (`Pty_unix.zig`).
//! Windows: ConPTY + PowerShell (`Pty_windows.zig`).
Expand All @@ -11,4 +12,15 @@ else

pub const supported = backend.supported;
pub const OnData = backend.OnData;
pub const OnExit = backend.OnExit;
pub const Pty = backend.Pty;

pub const Env = struct { name: []const u8, value: []const u8 };

/// A program to run in the terminal instead of the user's shell.
pub const Command = struct {
/// `argv[0]` is found on the child's `PATH` (`env` included) unless it names a path.
argv: []const []const u8,
/// Over the app's own environment, with `TERM=xterm-256color` unless this sets it.
env: []const Env = &.{},
};
Loading
Loading