diff --git a/include/ghostty.h b/include/ghostty.h index 40ff55c9b..4d4d375e1 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -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; diff --git a/src/Surface.zig b/src/Surface.zig index ebc2a2f43..a2b20ed1a 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -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| { diff --git a/src/apprt/action.zig b/src/apprt/action.zig index f6865af83..66d11862a 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -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, }; diff --git a/src/apprt/gtk/class/surface.zig b/src/apprt/gtk/class/surface.zig index 4b31c43d5..d0af331cb 100644 --- a/src/apprt/gtk/class/surface.zig +++ b/src/apprt/gtk/class/surface.zig @@ -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); diff --git a/src/apprt/surface.zig b/src/apprt/surface.zig index 3cb0016fa..872bc1b1f 100644 --- a/src/apprt/surface.zig +++ b/src/apprt/surface.zig @@ -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. diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index e41daa8be..2cb961360 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -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 => {