core: add 'command finished' notifications

Fixes #8991

Uses OSC 133 esc sequences to keep track of how long commands take to
execute. If the user chooses, commands that take longer than a user
specified limit will trigger a notification. The user can choose between
a bell notification or a desktop notification.
This commit is contained in:
Jeffrey C. Ollie
2025-10-01 23:48:08 -05:00
parent 9c8d2e577e
commit 07124dba64
12 changed files with 366 additions and 25 deletions

View File

@@ -295,6 +295,9 @@ pub const Action = union(Key) {
/// Show the on-screen keyboard.
show_on_screen_keyboard,
/// A command has finished,
command_finished: CommandFinished,
/// Sync with: ghostty_action_tag_e
pub const Key = enum(c_int) {
quit,
@@ -350,6 +353,7 @@ pub const Action = union(Key) {
show_child_exited,
progress_report,
show_on_screen_keyboard,
command_finished,
};
/// Sync with: ghostty_action_u
@@ -741,3 +745,21 @@ pub const CloseTabMode = enum(c_int) {
/// Close all other tabs.
other,
};
pub const CommandFinished = struct {
exit_code: ?u8,
duration: configpkg.Config.Duration,
/// sync with ghostty_action_command_finished_s in ghostty.h
pub const C = extern struct {
exit_code: i16,
duration: u64,
};
pub fn cval(self: CommandFinished) C {
return .{
.exit_code = self.exit_code orelse -1,
.duration = self.duration.duration,
};
}
};

View File

@@ -713,6 +713,7 @@ pub const Application = extern struct {
.toggle_command_palette => return Action.toggleCommandPalette(target),
.toggle_split_zoom => return Action.toggleSplitZoom(target),
.show_on_screen_keyboard => return Action.showOnScreenKeyboard(target),
.command_finished => return Action.commandFinished(target, value),
// Unimplemented
.secure_input,
@@ -1824,13 +1825,13 @@ const Action = struct {
target: apprt.Target,
n: apprt.action.DesktopNotification,
) void {
// TODO: We should move the surface target to a function call
// on Surface and emit a signal that embedders can connect to. This
// will let us handle notifications differently depending on where
// a surface is presented. At the time of writing this, we always
// want to show the notification AND the logic below was directly
// ported from "legacy" GTK so this is fine, but I want to leave this
// note so we can do it one day.
switch (target) {
.app => {},
.surface => |v| {
v.rt_surface.gobj().sendDesktopNotification(n.title, n.body);
return;
},
}
// Set a default title if we don't already have one
const t = switch (n.title.len) {
@@ -1845,14 +1846,9 @@ const Action = struct {
const icon = gio.ThemedIcon.new("com.mitchellh.ghostty");
defer icon.unref();
notification.setIcon(icon.as(gio.Icon));
const pointer = glib.Variant.newUint64(switch (target) {
.app => 0,
.surface => |v| @intFromPtr(v),
});
notification.setDefaultActionAndTargetValue(
"app.present-surface",
pointer,
glib.Variant.newUint64(0),
);
// We set the notification ID to the body content. If the content is the
@@ -2457,6 +2453,15 @@ const Action = struct {
},
}
}
pub fn commandFinished(target: apprt.Target, value: apprt.Action.Value(.command_finished)) bool {
switch (target) {
.app => return false,
.surface => |surface| {
return surface.rt_surface.gobj().commandFinished(value);
},
}
}
};
/// This sets various GTK-related environment variables as necessary

View File

@@ -198,7 +198,7 @@ pub const SplitTree = extern struct {
.init("zoom", actionZoom, null),
};
ext.actions.addAsGroup(Self, self, "split-tree", &actions);
_ = ext.actions.addAsGroup(Self, self, "split-tree", &actions);
}
/// Create a new split in the given direction from the currently

View File

@@ -32,6 +32,7 @@ const TitleDialog = @import("surface_title_dialog.zig").SurfaceTitleDialog;
const Window = @import("window.zig").Window;
const WeakRef = @import("../weak_ref.zig").WeakRef;
const InspectorWindow = @import("inspector_window.zig").InspectorWindow;
const i18n = @import("../../../os/i18n.zig");
const log = std.log.scoped(.gtk_ghostty_surface);
@@ -545,6 +546,8 @@ pub const Surface = extern struct {
// unfocused-split-* options
is_split: bool = false,
action_group: ?*gio.SimpleActionGroup = null,
// Template binds
child_exited_overlay: *ChildExited,
context_menu: *gtk.PopoverMenu,
@@ -809,6 +812,63 @@ pub const Surface = extern struct {
);
}
pub fn commandFinished(self: *Self, value: apprt.Action.Value(.command_finished)) bool {
const app = Application.default();
const alloc = app.allocator();
const priv: *Private = self.private();
const notify_next_command_finish = notify: {
const simple_action_group = priv.action_group orelse break :notify false;
const action_group = simple_action_group.as(gio.ActionGroup);
const state = action_group.getActionState("notify-on-next-command-finish") orelse break :notify false;
const bool_variant_type = glib.ext.VariantType.newFor(bool);
defer bool_variant_type.free();
if (state.isOfType(bool_variant_type) == 0) break :notify false;
const notify = state.getBoolean() != 0;
action_group.changeActionState("notify-on-next-command-finish", glib.Variant.newBoolean(@intFromBool(false)));
break :notify notify;
};
const config = priv.config orelse return false;
const cfg = config.get();
if (!notify_next_command_finish) {
if (cfg.@"notify-on-command-finish" == .never) return true;
if (cfg.@"notify-on-command-finish" == .unfocused and self.getFocused()) return true;
}
const action = cfg.@"notify-on-command-finish-action";
if (action.bell) self.setBellRinging(true);
if (action.notify) notify: {
const title_ = title: {
const exit_code = value.exit_code orelse break :title i18n._("Command Finished");
if (exit_code == 0) break :title i18n._("Command Succeeded");
break :title i18n._("Command Failed");
};
const title = std.mem.span(title_);
const body = body: {
const exit_code = value.exit_code orelse break :body std.fmt.allocPrintZ(
alloc,
"Command took {}.",
.{value.duration.round(std.time.ns_per_ms)},
) catch break :notify;
break :body std.fmt.allocPrintZ(
alloc,
"Command took {} and exited with code {d}.",
.{ value.duration.round(std.time.ns_per_ms), exit_code },
) catch break :notify;
};
defer alloc.free(body);
self.sendDesktopNotification(title, body);
}
return true;
}
/// Key press event (press or release).
///
/// At a high level, we want to construct an `input.KeyEvent` and
@@ -1404,6 +1464,34 @@ pub const Surface = extern struct {
_ = priv.gl_area.as(gtk.Widget).grabFocus();
}
pub fn sendDesktopNotification(self: *Self, title: [:0]const u8, body: [:0]const u8) void {
const app = Application.default();
const t = switch (title.len) {
0 => "Ghostty",
else => title,
};
const notification = gio.Notification.new(t);
defer notification.unref();
notification.setBody(body);
const icon = gio.ThemedIcon.new("com.mitchellh.ghostty");
defer icon.unref();
notification.setIcon(icon.as(gio.Icon));
const pointer = glib.Variant.newUint64(@intFromPtr(self));
notification.setDefaultActionAndTargetValue(
"app.present-surface",
pointer,
);
// We set the notification ID to the body content. If the content is the
// same, this notification may replace a previous notification
const gio_app = app.as(gio.Application);
gio_app.sendNotification(body, notification);
}
//---------------------------------------------------------------
// Virtual Methods
@@ -1460,11 +1548,23 @@ pub const Surface = extern struct {
}
fn initActionMap(self: *Self) void {
const priv: *Private = self.private();
const actions = [_]ext.actions.Action(Self){
.init("prompt-title", actionPromptTitle, null),
.init(
"prompt-title",
actionPromptTitle,
null,
),
.initStateful(
"notify-on-next-command-finish",
actionNotifyOnNextCommandFinish,
null,
glib.Variant.newBoolean(@intFromBool(false)),
),
};
ext.actions.addAsGroup(Self, self, "surface", &actions);
priv.action_group = ext.actions.addAsGroup(Self, self, "surface", &actions);
}
fn dispose(self: *Self) callconv(.c) void {
@@ -1966,6 +2066,20 @@ pub const Surface = extern struct {
};
}
pub fn actionNotifyOnNextCommandFinish(
action: *gio.SimpleAction,
_: ?*glib.Variant,
_: *Self,
) callconv(.c) void {
const state = action.as(gio.Action).getState() orelse glib.Variant.newBoolean(@intFromBool(false));
defer state.unref();
const bool_variant_type = glib.ext.VariantType.newFor(bool);
defer bool_variant_type.free();
if (state.isOfType(bool_variant_type) == 0) return;
const value = state.getBoolean() != 0;
action.setState(glib.Variant.newBoolean(@intFromBool(!value)));
}
fn childExitedClose(
_: *ChildExited,
self: *Self,

View File

@@ -206,7 +206,7 @@ pub const Tab = extern struct {
.init("ring-bell", actionRingBell, null),
};
ext.actions.addAsGroup(Self, self, "tab", &actions);
_ = ext.actions.addAsGroup(Self, self, "tab", &actions);
}
//---------------------------------------------------------------

View File

@@ -40,14 +40,21 @@ test "gActionNameIsValid" {
/// Function to create a structure for describing an action.
pub fn Action(comptime T: type) type {
return struct {
const Self = @This();
pub const Callback = *const fn (*gio.SimpleAction, ?*glib.Variant, *T) callconv(.c) void;
name: [:0]const u8,
callback: Callback,
parameter_type: ?*const glib.VariantType,
state: ?*glib.Variant = null,
/// Function to initialize a new action so that we can comptime check the name.
pub fn init(comptime name: [:0]const u8, callback: Callback, parameter_type: ?*const glib.VariantType) @This() {
/// Function to initialize a new action so that we can comptime check
/// the name.
pub fn init(
comptime name: [:0]const u8,
callback: Callback,
parameter_type: ?*const glib.VariantType,
) Self {
comptime assert(gActionNameIsValid(name));
return .{
@@ -56,6 +63,23 @@ pub fn Action(comptime T: type) type {
.parameter_type = parameter_type,
};
}
/// Function to initialize a new stateful action so that we can comptime
/// check the name.
pub fn initStateful(
comptime name: [:0]const u8,
callback: Callback,
parameter_type: ?*const glib.VariantType,
state: *glib.Variant,
) Self {
comptime assert(gActionNameIsValid(name));
return .{
.name = name,
.callback = callback,
.parameter_type = parameter_type,
.state = state,
};
}
};
}
@@ -68,10 +92,19 @@ pub fn add(comptime T: type, self: *T, actions: []const Action(T)) void {
pub fn addToMap(comptime T: type, self: *T, map: *gio.ActionMap, actions: []const Action(T)) void {
for (actions) |entry| {
assert(gActionNameIsValid(entry.name));
const action = gio.SimpleAction.new(
entry.name,
entry.parameter_type,
);
const action = action: {
if (entry.state) |state| {
break :action gio.SimpleAction.newStateful(
entry.name,
entry.parameter_type,
state,
);
}
break :action gio.SimpleAction.new(
entry.name,
entry.parameter_type,
);
};
defer action.unref();
_ = gio.SimpleAction.signals.activate.connect(
action,
@@ -85,7 +118,7 @@ pub fn addToMap(comptime T: type, self: *T, map: *gio.ActionMap, actions: []cons
}
/// Add actions to a widget that doesn't implement ActionGroup directly.
pub fn addAsGroup(comptime T: type, self: *T, comptime name: [:0]const u8, actions: []const Action(T)) void {
pub fn addAsGroup(comptime T: type, self: *T, comptime name: [:0]const u8, actions: []const Action(T)) *gio.SimpleActionGroup {
comptime assert(gActionNameIsValid(name));
// Collect our actions into a group since we're just a plain widget that
@@ -99,6 +132,8 @@ pub fn addAsGroup(comptime T: type, self: *T, comptime name: [:0]const u8, actio
name,
group.as(gio.ActionGroup),
);
return group;
}
test "adding actions to an object" {
@@ -138,7 +173,7 @@ test "adding actions to an object" {
.init("test", callbacks.callback, i32_variant_type),
};
addAsGroup(gtk.Box, box, "test", &actions);
_ = addAsGroup(gtk.Box, box, "test", &actions);
}
const expected = std.crypto.random.intRangeAtMost(i32, 1, std.math.maxInt(u31));

View File

@@ -203,6 +203,11 @@ menu context_menu_model {
label: _("Paste");
action: "win.paste";
}
item {
label: _("Notify on Next Command Finish");
action: "surface.notify-on-next-command-finish";
}
}
section {

View File

@@ -96,6 +96,14 @@ pub const Message = union(enum) {
/// Report the progress of an action using a GUI element
progress_report: terminal.osc.Command.ProgressReport,
/// A command has started in the shell, start a timer.
start_command_timer,
/// A command has finished in the shell, stop the timer and send out
/// notifications as appropriate. The optional u8 is the exit code
/// of the command.
stop_command_timer: ?u8,
pub const ReportTitleStyle = enum {
csi_21_t,