summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--include/ghostty.h10
-rw-r--r--src/Surface.zig44
-rw-r--r--src/apprt/action.zig22
-rw-r--r--src/apprt/gtk/class/application.zig31
-rw-r--r--src/apprt/gtk/class/split_tree.zig2
-rw-r--r--src/apprt/gtk/class/surface.zig118
-rw-r--r--src/apprt/gtk/class/tab.zig2
-rw-r--r--src/apprt/gtk/ext/actions.zig53
-rw-r--r--src/apprt/gtk/ui/1.2/surface.blp5
-rw-r--r--src/apprt/surface.zig8
-rw-r--r--src/config/Config.zig93
-rw-r--r--src/termio/stream_handler.zig5
12 files changed, 367 insertions, 26 deletions
diff --git a/include/ghostty.h b/include/ghostty.h
index 3f1e0c9d9..48836ee96 100644
--- a/include/ghostty.h
+++ b/include/ghostty.h
@@ -733,6 +733,14 @@ typedef struct {
int8_t progress;
} ghostty_action_progress_report_s;
+// apprt.action.CommandFinished.C
+typedef struct {
+ // -1 if no exit code was reported, otherwise 0-255
+ int16_t exit_code;
+ // number of nanoseconds that command was running for
+ uint64_t duration;
+} ghostty_action_command_finished_s;
+
// apprt.Action.Key
typedef enum {
GHOSTTY_ACTION_QUIT,
@@ -788,6 +796,7 @@ typedef enum {
GHOSTTY_ACTION_SHOW_CHILD_EXITED,
GHOSTTY_ACTION_PROGRESS_REPORT,
GHOSTTY_ACTION_SHOW_ON_SCREEN_KEYBOARD,
+ GHOSTTY_ACTION_COMMAND_FINISHED,
} ghostty_action_tag_e;
typedef union {
@@ -819,6 +828,7 @@ typedef union {
ghostty_action_close_tab_mode_e close_tab_mode;
ghostty_surface_message_childexited_s child_exited;
ghostty_action_progress_report_s progress_report;
+ ghostty_action_command_finished_s command_finished;
} ghostty_action_u;
typedef struct {
diff --git a/src/Surface.zig b/src/Surface.zig
index 03974dfc6..637af80cb 100644
--- a/src/Surface.zig
+++ b/src/Surface.zig
@@ -33,6 +33,7 @@ const font = @import("font/main.zig");
const Command = @import("Command.zig");
const terminal = @import("terminal/main.zig");
const configpkg = @import("config.zig");
+const Duration = configpkg.Config.Duration;
const input = @import("input.zig");
const App = @import("App.zig");
const internal_os = @import("os/main.zig");
@@ -147,6 +148,13 @@ focused: bool = true,
/// Used to determine whether to continuously scroll.
selection_scroll_active: bool = false,
+/// Used to send notifications that long running commands have finished.
+/// Requires that shell integration be active. Should represent a nanosecond
+/// precision timestamp. It does not necessarily need to correspond to the
+/// actual time, but we must be able to compare two subsequent timestamps to get
+/// the wall clock time that has elapsed between timestamps.
+command_timer: ?i128 = null,
+
/// The effect of an input event. This can be used by callers to take
/// the appropriate action after an input event. For example, key
/// input can be forwarded to the OS for further processing if it
@@ -280,6 +288,9 @@ const DerivedConfig = struct {
links: []Link,
link_previews: configpkg.LinkPreviews,
scroll_to_bottom: configpkg.Config.ScrollToBottom,
+ notify_on_command_finish: configpkg.Config.NotifyOnCommandFinish,
+ notify_on_command_finish_action: configpkg.Config.NotifyOnCommandFinishAction,
+ notify_on_command_finish_after: Duration,
const Link = struct {
regex: oni.Regex,
@@ -350,6 +361,9 @@ const DerivedConfig = struct {
.links = links,
.link_previews = config.@"link-previews",
.scroll_to_bottom = config.@"scroll-to-bottom",
+ .notify_on_command_finish = config.@"notify-on-command-finish",
+ .notify_on_command_finish_action = config.@"notify-on-command-finish-action",
+ .notify_on_command_finish_after = config.@"notify-on-command-finish-after",
// Assignments happen sequentially so we have to do this last
// so that the memory is captured from allocs above.
@@ -984,6 +998,36 @@ pub fn handleMessage(self: *Surface, msg: Message) !void {
self.selection_scroll_active = active;
try self.selectionScrollTick();
},
+
+ .start_command_timer => {
+ self.command_timer = std.time.nanoTimestamp();
+ },
+
+ .stop_command_timer => |v| timer: {
+ const end = std.time.nanoTimestamp();
+ const start = self.command_timer orelse break :timer;
+ self.command_timer = null;
+
+ const difference = end - start;
+
+ // skip obviously silly results
+ if (difference < 0) break :timer;
+ if (difference > std.math.maxInt(u64)) break :timer;
+
+ const duration: Duration = .{ .duration = @intCast(difference) };
+ log.debug("command took {}", .{duration});
+
+ _ = self.rt_app.performAction(
+ .{ .surface = self },
+ .command_finished,
+ .{
+ .exit_code = v,
+ .duration = duration,
+ },
+ ) catch |err| {
+ log.warn("apprt failed to notify command finish={}", .{err});
+ };
+ },
}
}
diff --git a/src/apprt/action.zig b/src/apprt/action.zig
index b7dc80e03..b356ff32f 100644
--- a/src/apprt/action.zig
+++ b/src/apprt/action.zig
@@ -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,
+ };
+ }
+};
diff --git a/src/apprt/gtk/class/application.zig b/src/apprt/gtk/class/application.zig
index f7ed0d38c..90c72681d 100644
--- a/src/apprt/gtk/class/application.zig
+++ b/src/apprt/gtk/class/application.zig
@@ -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
diff --git a/src/apprt/gtk/class/split_tree.zig b/src/apprt/gtk/class/split_tree.zig
index 755b51e9a..977a7eab2 100644
--- a/src/apprt/gtk/class/split_tree.zig
+++ b/src/apprt/gtk/class/split_tree.zig
@@ -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
diff --git a/src/apprt/gtk/class/surface.zig b/src/apprt/gtk/class/surface.zig
index 344bf8f21..401e542e4 100644
--- a/src/apprt/gtk/class/surface.zig
+++ b/src/apprt/gtk/class/surface.zig
@@ -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,
diff --git a/src/apprt/gtk/class/tab.zig b/src/apprt/gtk/class/tab.zig
index d8f9b97f8..373507507 100644
--- a/src/apprt/gtk/class/tab.zig
+++ b/src/apprt/gtk/class/tab.zig
@@ -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);
}
//---------------------------------------------------------------
diff --git a/src/apprt/gtk/ext/actions.zig b/src/apprt/gtk/ext/actions.zig
index 8499e7de8..344c08e05 100644
--- a/src/apprt/gtk/ext/actions.zig
+++ b/src/apprt/gtk/ext/actions.zig
@@ -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,
-
- /// 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() {
+ 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,
+ ) 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));
diff --git a/src/apprt/gtk/ui/1.2/surface.blp b/src/apprt/gtk/ui/1.2/surface.blp
index 7ed78ecb3..84e00ac4a 100644
--- a/src/apprt/gtk/ui/1.2/surface.blp
+++ b/src/apprt/gtk/ui/1.2/surface.blp
@@ -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 {
diff --git a/src/apprt/surface.zig b/src/apprt/surface.zig
index e4effe128..70866c609 100644
--- a/src/apprt/surface.zig
+++ b/src/apprt/surface.zig
@@ -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,
diff --git a/src/config/Config.zig b/src/config/Config.zig
index b7a9699c8..f33936e00 100644
--- a/src/config/Config.zig
+++ b/src/config/Config.zig
@@ -1004,6 +1004,82 @@ command: ?Command = null,
/// manually.
@"initial-command": ?Command = null,
+/// Controls when command finished notifications are sent. There are
+/// three options:
+///
+/// * `never` - Never send notifications (the default).
+/// * `unfocused` - Only send notifications if the surface that the command is
+/// running in is not focused.
+/// * `always` - Always send notifications.
+///
+/// Command finished notifications requires that either shell integration is
+/// enabled, or that your shell sends OSC 133 escape sequences to mark the start
+/// and end of commands.
+///
+/// On GTK, there is a context menu item that will enable command finished
+/// notifications for a single command, overriding the `never` and `unfocused`
+/// options.
+///
+/// GTK only.
+///
+/// Available since 1.3.0.
+@"notify-on-command-finish": NotifyOnCommandFinish = .never,
+
+/// If command finished notifications are enabled, this controls how the user is
+/// notified.
+///
+/// Available options:
+///
+/// * `bell` - enabled by default
+/// * `notify` - disabled by default
+///
+/// Options can be combined by listing them as a comma separated list. Options
+/// can be negated by prefixing them with `no-`. For example `no-bell,notify`.
+///
+/// GTK only.
+///
+/// Available since 1.3.0.
+@"notify-on-command-finish-action": NotifyOnCommandFinishAction = .{
+ .bell = true,
+ .notify = false,
+},
+
+/// If command finished notifications are enabled, this controls how long a
+/// command must have been running before a notification will be sent. The
+/// default is five seconds.
+///
+/// The duration is specified as a series of numbers followed by time units.
+/// Whitespace is allowed between numbers and units. Each number and unit will
+/// be added together to form the total duration.
+///
+/// The allowed time units are as follows:
+///
+/// * `y` - 365 SI days, or 8760 hours, or 31536000 seconds. No adjustments
+/// are made for leap years or leap seconds.
+/// * `d` - one SI day, or 86400 seconds.
+/// * `h` - one hour, or 3600 seconds.
+/// * `m` - one minute, or 60 seconds.
+/// * `s` - one second.
+/// * `ms` - one millisecond, or 0.001 second.
+/// * `us` or `µs` - one microsecond, or 0.000001 second.
+/// * `ns` - one nanosecond, or 0.000000001 second.
+///
+/// Examples:
+/// * `1h30m`
+/// * `45s`
+///
+/// Units can be repeated and will be added together. This means that
+/// `1h1h` is equivalent to `2h`. This is confusing and should be avoided.
+/// A future update may disallow this.
+///
+/// The maximum value is `584y 49w 23h 34m 33s 709ms 551µs 615ns`. Any
+/// value larger than this will be clamped to the maximum value.
+///
+/// GTK only.
+///
+/// Available since 1.3.0
+@"notify-on-command-finish-after": Duration = .{ .duration = 5 * std.time.ns_per_s },
+
/// Extra environment variables to pass to commands launched in a terminal
/// surface. The format is `env=KEY=VALUE`.
///
@@ -8165,6 +8241,10 @@ pub const Duration = struct {
return .{ .duration = self.duration / to * to };
}
+ pub fn lte(self: Duration, other: Duration) bool {
+ return self.duration <= other.duration;
+ }
+
pub fn parseCLI(input: ?[]const u8) !Duration {
var remaining = input orelse return error.ValueRequired;
@@ -8378,6 +8458,19 @@ pub const ScrollToBottom = packed struct {
pub const default: ScrollToBottom = .{};
};
+/// See notify-on-command-finish
+pub const NotifyOnCommandFinish = enum {
+ never,
+ unfocused,
+ always,
+};
+
+/// See notify-on-command-finish-action
+pub const NotifyOnCommandFinishAction = packed struct {
+ bell: bool = true,
+ notify: bool = false,
+};
+
test "parse duration" {
inline for (Duration.units) |unit| {
var buf: [16]u8 = undefined;
diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig
index 2d90831f2..b2b2af3d0 100644
--- a/src/termio/stream_handler.zig
+++ b/src/termio/stream_handler.zig
@@ -1054,6 +1054,11 @@ pub const StreamHandler = struct {
pub inline fn endOfInput(self: *StreamHandler) !void {
self.terminal.markSemanticPrompt(.command);
+ self.surfaceMessageWriter(.start_command_timer);
+ }
+
+ pub inline fn endOfCommand(self: *StreamHandler, exit_code: ?u8) !void {
+ self.surfaceMessageWriter(.{ .stop_command_timer = exit_code });
}
pub fn reportPwd(self: *StreamHandler, url: []const u8) !void {