core/gtk: use command line supplied by the shell in notifications

Some shells use OSC 133;C to tell us what command is being executed.
Use that information to enhance our notifications that a command has
finished.
pull/9134/head
Jeffrey C. Ollie 2026-02-26 19:12:05 -06:00
parent 8aab822954
commit 16bdb58c92
No known key found for this signature in database
GPG Key ID: 1BB9EB7EA602265B
6 changed files with 158 additions and 32 deletions

View File

@ -826,9 +826,20 @@ typedef struct {
// apprt.action.CommandFinished.C
typedef struct {
// -1 if no exit code was reported, otherwise 0-255
// The command line or len==0 if the command line
// is unknown.
struct {
const char *ptr;
uint64_t len;
} command_line;
// The exit code of the command. The exit code will be a number between 0
// and 255 or -1 if no exit code was provided. 0 indicates that the command
// was successful. Any number from 1 to 255 indicates an application specific
// error code.
int16_t exit_code;
// number of nanoseconds that command was running for
// How long the command took in nanoseconds. Despite the duration being
// reported in nanoseconds the accuracy is probably only within a few
// milliseconds.
uint64_t duration;
} ghostty_action_command_finished_s;

View File

@ -155,13 +155,19 @@ selection_scroll_active: bool = false,
/// always enabled in this state.
readonly: 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
/// Timestamp used to send notifications that long running commands have
/// finished. Requires that the shell reports to Ghostty when a command stops
/// and start. The timestamp 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: ?std.time.Instant = null,
/// The command that is being executed, as reported by by the shell. If shell
/// does not report the command line this will always be null. This will never
/// be the `command` or `initial-command` that was used to start the shell.
/// Ghostty's shell integration does not supply the command line being executed.
command_line: ?[]const u8 = null,
/// Search state
search: ?Search = null,
@ -806,6 +812,14 @@ pub fn deinit(self: *Surface) void {
self.alloc.destroy(v);
}
// If we're still storing a command line, deallocate it. This could happen
// if a shell sends a report that a command started containing a command
// line but doesn't send a report that the command finished before the shell
// exits.
if (self.command_line) |command_line| {
self.alloc.free(command_line);
}
// Clean up our keyboard state
for (self.keyboard.sequence_queued.items) |req| req.deinit();
self.keyboard.sequence_queued.deinit(self.alloc);
@ -1110,10 +1124,32 @@ pub fn handleMessage(self: *Surface, msg: Message) !void {
try self.selectionScrollTick();
},
.start_command => {
// A command has started executing.
.start_command => |start_command| {
defer start_command.deinit();
self.command_timer = try .now();
if (start_command.command_line) |new_command_line| {
// Deallocate old command line if we are setting a new one. We
// don't deallocate the command line unconditionally because
// there are situations where the shell sends a bare command
// started report _after_ it has already sent a command started
// report with the command line but before it sends a command
// finished report.
if (self.command_line) |old_command_line| {
self.alloc.free(old_command_line);
self.command_line = null;
}
// Create our own copy of the command line, the copy that
// comes in the message could be deallocated once we are done
// processing the message.
self.command_line = self.alloc.dupe(u8, new_command_line.slice()) catch null;
}
},
// A command has finished executing.
.stop_command => |v| timer: {
const end: std.time.Instant = try .now();
const start = self.command_timer orelse break :timer;
@ -1126,12 +1162,19 @@ pub fn handleMessage(self: *Surface, msg: Message) !void {
.{ .surface = self },
.command_finished,
.{
.command_line = self.command_line,
.exit_code = v,
.duration = duration,
},
) catch |err| {
log.warn("apprt failed to notify command finish={}", .{err});
};
// Free up memory as the command line should never be used again.
if (self.command_line) |old_command_line| {
self.alloc.free(old_command_line);
self.command_line = null;
}
},
.search_total => |v| {

View File

@ -455,8 +455,8 @@ pub const Action = union(Key) {
// At the time of writing, we don't promise ABI compatibility
// so we can change this but I want to be aware of it.
assert(@sizeOf(CValue) == switch (@sizeOf(usize)) {
4 => 16,
8 => 24,
4 => 20,
8 => 32,
else => unreachable,
});
}
@ -940,17 +940,38 @@ pub const CloseTabMode = enum(c_int) {
};
pub const CommandFinished = struct {
/// The command line, as reported by the shell, or null if the command line
/// is unknown.
command_line: ?[]const u8,
/// The exit code, as reported by the shell. The exit code will be a number
/// between 0 and 255 or null if no exit code was provided. 0 indicates
/// that the command was successful. Any number from 1 to 255 indicates an
/// application specific error code.
exit_code: ?u8,
/// How long the command took in nanoseconds. Despite the duration being
/// reported in nanoseconds the accuracy is probably only within a few
/// milliseconds.
duration: configpkg.Config.Duration,
/// sync with ghostty_action_command_finished_s in ghostty.h
pub const C = extern struct {
/// The command line, as reported by the shell, or null if the command
/// line is unknown.
command_line: lib.String,
/// The exit code, as reported by the shell. The exit code will be a
/// number between 0 and 255 or null if no exit code was provided. 0
/// indicates that the command was successful. Any number from 1 to 255
/// indicates an application specific error code.
exit_code: i16,
// How long the command took in nanoseconds. Despite the duration being
// reported in nanoseconds the accuracy is probably only within a few
// milliseconds.
duration: u64,
};
pub fn cval(self: CommandFinished) C {
return .{
.command_line = .init(self.command_line orelse ""),
.exit_code = self.exit_code orelse -1,
.duration = self.duration.duration,
};

View File

@ -1136,26 +1136,45 @@ pub const Surface = extern struct {
if (action.bell) self.setBellRinging(true);
if (action.notify) notify: {
const title_ = title: {
const title = std.mem.span(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.allocPrintSentinel(
alloc,
"Command took {f}.",
.{value.duration.round(std.time.ns_per_ms)},
0,
) catch break :notify;
break :body std.fmt.allocPrintSentinel(
alloc,
"Command took {f} and exited with code {d}.",
.{ value.duration.round(std.time.ns_per_ms), exit_code },
0,
) catch break :notify;
};
});
var buf: std.Io.Writer.Allocating = .init(alloc);
defer buf.deinit();
const writer = &buf.writer;
writer.writeAll("Command ") catch break :notify;
if (value.command_line) |command_line| command_line: {
// Don't bother with a zero length command line.
if (command_line.len == 0) break :command_line;
// The defacto standard for "hiding" a command line from being
// saved in history or other places is to prefix it with a
// space. Honor that here.
if (command_line[0] == ' ') {
writer.writeAll("«hidden» ") catch break :notify;
break :command_line;
}
writer.writeAll("") catch break :notify;
writer.writeAll(command_line) catch break :notify;
writer.writeAll("") catch break :notify;
}
writer.print(
"took {f}",
.{value.duration.round(std.time.ns_per_ms)},
) catch break :notify;
if (value.exit_code) |exit_code| {
writer.print(" and exited with code {d}", .{exit_code}) catch break :notify;
}
writer.writeByte('.') catch break :notify;
const body = buf.toOwnedSliceSentinel(0) catch break :notify;
defer alloc.free(body);
self.sendDesktopNotification(title, body);

View File

@ -91,12 +91,13 @@ 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,
/// A command has started in the shell. Start a timer and store the command
/// line (if provided) for later display.
start_command: StartCommand,
/// 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.
/// 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: ?u8,
/// The scrollbar state changed for the surface.
@ -129,6 +130,25 @@ pub const Message = union(enum) {
.none => void,
};
};
pub const StartCommand = struct {
command_line: ?WriteReq,
/// Return the command line, or null if no command line was supplied.
pub fn init(alloc: std.mem.Allocator, command_line: []const u8) StartCommand {
if (command_line.len == 0) return .{
.command_line = null,
};
return .{
.command_line = WriteReq.init(alloc, command_line) catch null,
};
}
pub fn deinit(self: *const StartCommand) void {
if (self.command_line) |command_line| command_line.deinit();
}
};
};
/// A surface mailbox.

View File

@ -4,6 +4,7 @@ const assert = @import("../quirks.zig").inlineAssert;
const Allocator = std.mem.Allocator;
const xev = @import("../global.zig").xev;
const apprt = @import("../apprt.zig");
const Message = apprt.surface.Message;
const build_config = @import("../build_config.zig");
const configpkg = @import("../config.zig");
const internal_os = @import("../os/main.zig");
@ -124,7 +125,7 @@ pub const StreamHandler = struct {
inline fn surfaceMessageWriter(
self: *StreamHandler,
msg: apprt.surface.Message,
msg: Message,
) void {
// See messageWriter which has similar logic and explains why
// we may have to do this.
@ -1082,7 +1083,18 @@ pub const StreamHandler = struct {
) !void {
switch (cmd.action) {
.end_input_start_output => {
self.surfaceMessageWriter(.start_command);
const message: Message = .{
.start_command = c: {
var w: std.Io.Writer.Allocating = .init(self.alloc);
defer w.deinit();
cmd.writeCommandLine(&w.writer) catch break :c .{ .command_line = null };
break :c Message.StartCommand.init(self.alloc, w.written());
},
};
self.surfaceMessageWriter(message);
},
.end_command => {