mirror of
https://github.com/ghostty-org/ghostty.git
synced 2025-07-16 08:46:08 +03:00
Merge pull request #149 from mitchellh/osc7
OSC 7 (report pwd) and new Window/Tab/Split uses current cwd
This commit is contained in:
10
README.md
10
README.md
@ -93,14 +93,16 @@ Ghostty supports some features that require shell integration. I am aiming
|
||||
to support many of the features that
|
||||
[Kitty supports for shell integration](https://sw.kovidgoyal.net/kitty/shell-integration/).
|
||||
|
||||
Today, the most important quality-of-life feature is that Ghostty will
|
||||
not confirm window close if it detects that the cursor is sitting at a prompt
|
||||
input (i.e. no subprocess is running).
|
||||
|
||||
To enable this functionality, I recommend sourcing Kitty's shell integration
|
||||
files directly for your shell configuration when running Ghostty. For
|
||||
example, for fish, [source this file](https://github.com/kovidgoyal/kitty/blob/master/shell-integration/fish/vendor_conf.d/kitty-shell-integration.fish).
|
||||
|
||||
The currently support shell integration features in Ghostty:
|
||||
|
||||
* We do not confirm close for windows where the cursor is at a prompt.
|
||||
* New terminals start in the working directory of the previously focused terminal.
|
||||
* The cursor at the prompt is turned into a bar.
|
||||
|
||||
## Roadmap and Status
|
||||
|
||||
The high-level ambitious plan for the project, in order:
|
||||
|
27
src/App.zig
27
src/App.zig
@ -29,6 +29,10 @@ alloc: Allocator,
|
||||
/// The list of surfaces that are currently active.
|
||||
surfaces: SurfaceList,
|
||||
|
||||
/// The last focused surface. This surface may not be valid;
|
||||
/// you must always call hasSurface to validate it.
|
||||
focused_surface: ?*Surface = null,
|
||||
|
||||
/// The mailbox that can be used to send this thread messages. Note
|
||||
/// this is a blocking queue so if it is full you will get errors (or block).
|
||||
mailbox: Mailbox.Queue,
|
||||
@ -123,6 +127,14 @@ pub fn deleteSurface(self: *App, rt_surface: *apprt.Surface) void {
|
||||
}
|
||||
}
|
||||
|
||||
/// The last focused surface. This is only valid while on the main thread
|
||||
/// before tick is called.
|
||||
pub fn focusedSurface(self: *const App) ?*Surface {
|
||||
const surface = self.focused_surface orelse return null;
|
||||
if (!self.hasSurface(surface)) return null;
|
||||
return surface;
|
||||
}
|
||||
|
||||
/// Drain the mailbox.
|
||||
fn drainMailbox(self: *App, rt_app: *apprt.App) !void {
|
||||
while (self.mailbox.pop()) |message| {
|
||||
@ -131,6 +143,7 @@ fn drainMailbox(self: *App, rt_app: *apprt.App) !void {
|
||||
.reload_config => try self.reloadConfig(rt_app),
|
||||
.new_window => |msg| try self.newWindow(rt_app, msg),
|
||||
.close => |surface| try self.closeSurface(rt_app, surface),
|
||||
.focus => |surface| try self.focusSurface(rt_app, surface),
|
||||
.quit => try self.setQuit(),
|
||||
.surface_message => |msg| try self.surfaceMessage(msg.surface, msg.message),
|
||||
.redraw_surface => |surface| try self.redrawSurface(rt_app, surface),
|
||||
@ -153,6 +166,13 @@ fn closeSurface(self: *App, rt_app: *apprt.App, surface: *Surface) !void {
|
||||
surface.close();
|
||||
}
|
||||
|
||||
fn focusSurface(self: *App, rt_app: *apprt.App, surface: *Surface) !void {
|
||||
_ = rt_app;
|
||||
|
||||
if (!self.hasSurface(surface)) return;
|
||||
self.focused_surface = surface;
|
||||
}
|
||||
|
||||
fn redrawSurface(self: *App, rt_app: *apprt.App, surface: *apprt.Surface) !void {
|
||||
if (!self.hasSurface(&surface.core_surface)) return;
|
||||
rt_app.redrawSurface(surface);
|
||||
@ -194,7 +214,7 @@ fn surfaceMessage(self: *App, surface: *Surface, msg: apprt.surface.Message) !vo
|
||||
// Not a problem.
|
||||
}
|
||||
|
||||
fn hasSurface(self: *App, surface: *Surface) bool {
|
||||
fn hasSurface(self: *const App, surface: *const Surface) bool {
|
||||
for (self.surfaces.items) |v| {
|
||||
if (&v.core_surface == surface) return true;
|
||||
}
|
||||
@ -215,6 +235,11 @@ pub const Message = union(enum) {
|
||||
/// should close.
|
||||
close: *Surface,
|
||||
|
||||
/// The last focused surface. The app keeps track of this to
|
||||
/// enable "inheriting" various configurations from the last
|
||||
/// surface.
|
||||
focus: *Surface,
|
||||
|
||||
/// Quit
|
||||
quit: void,
|
||||
|
||||
|
@ -631,6 +631,16 @@ fn changeConfig(self: *Surface, config: *const configpkg.Config) !void {
|
||||
};
|
||||
}
|
||||
|
||||
/// Returns the pwd of the terminal, if any. This is always copied because
|
||||
/// the pwd can change at any point from termio. If we are calling from the IO
|
||||
/// thread you should just check the terminal directly.
|
||||
pub fn pwd(self: *const Surface, alloc: Allocator) !?[]const u8 {
|
||||
self.renderer_state.mutex.lock();
|
||||
defer self.renderer_state.mutex.unlock();
|
||||
const terminal_pwd = self.io.terminal.getPwd() orelse return null;
|
||||
return try alloc.dupe(u8, terminal_pwd);
|
||||
}
|
||||
|
||||
/// Returns the x/y coordinate of where the IME (Input Method Editor)
|
||||
/// keyboard should be rendered.
|
||||
pub fn imePoint(self: *const Surface) apprt.IMEPos {
|
||||
@ -1224,6 +1234,13 @@ pub fn focusCallback(self: *Surface, focused: bool) !void {
|
||||
.focus = focused,
|
||||
}, .{ .forever = {} });
|
||||
|
||||
// Notify our app if we gained focus.
|
||||
if (focused) {
|
||||
_ = self.app_mailbox.push(.{
|
||||
.focus = self,
|
||||
}, .{ .forever = {} });
|
||||
}
|
||||
|
||||
// Schedule render which also drains our mailbox
|
||||
try self.queueRender();
|
||||
|
||||
|
@ -161,11 +161,15 @@ pub const Surface = struct {
|
||||
try app.core_app.addSurface(self);
|
||||
errdefer app.core_app.deleteSurface(self);
|
||||
|
||||
// Shallow copy the config so that we can modify it.
|
||||
var config = try apprt.surface.newConfig(app.core_app, app.config);
|
||||
defer config.deinit();
|
||||
|
||||
// Initialize our surface right away. We're given a view that is
|
||||
// ready to use.
|
||||
try self.core_surface.init(
|
||||
app.core_app.alloc,
|
||||
app.config,
|
||||
&config,
|
||||
.{ .rt_app = app, .mailbox = &app.core_app.mailbox },
|
||||
self,
|
||||
);
|
||||
|
@ -346,10 +346,14 @@ pub const Surface = struct {
|
||||
try app.app.addSurface(self);
|
||||
errdefer app.app.deleteSurface(self);
|
||||
|
||||
// Get our new surface config
|
||||
var config = try apprt.surface.newConfig(app.app, &app.config);
|
||||
defer config.deinit();
|
||||
|
||||
// Initialize our surface now that we have the stable pointer.
|
||||
try self.core_surface.init(
|
||||
app.app.alloc,
|
||||
&app.config,
|
||||
&config,
|
||||
.{ .rt_app = app, .mailbox = &app.app.mailbox },
|
||||
self,
|
||||
);
|
||||
|
@ -722,10 +722,14 @@ pub const Surface = struct {
|
||||
try self.app.core_app.addSurface(self);
|
||||
errdefer self.app.core_app.deleteSurface(self);
|
||||
|
||||
// Get our new surface config
|
||||
var config = try apprt.surface.newConfig(self.app.core_app, &self.app.config);
|
||||
defer config.deinit();
|
||||
|
||||
// Initialize our surface now that we have the stable pointer.
|
||||
try self.core_surface.init(
|
||||
self.app.core_app.alloc,
|
||||
&self.app.config,
|
||||
&config,
|
||||
.{ .rt_app = self.app, .mailbox = &self.app.core_app.mailbox },
|
||||
self,
|
||||
);
|
||||
|
@ -61,3 +61,24 @@ pub const Mailbox = struct {
|
||||
}, timeout);
|
||||
}
|
||||
};
|
||||
|
||||
/// Returns a new config for a surface for the given app that should be
|
||||
/// used for any new surfaces. The resulting config should be deinitialized
|
||||
/// after the surface is initialized.
|
||||
pub fn newConfig(app: *const App, config: *const Config) !Config {
|
||||
// Create a shallow clone
|
||||
var copy = config.shallowClone(app.alloc);
|
||||
|
||||
// Our allocator is our config's arena
|
||||
const alloc = copy._arena.?.allocator();
|
||||
|
||||
// Get our previously focused surface for some inherited values.
|
||||
const prev = app.focusedSurface();
|
||||
if (prev) |p| {
|
||||
if (try p.pwd(alloc)) |pwd| {
|
||||
copy.@"working-directory" = pwd;
|
||||
}
|
||||
}
|
||||
|
||||
return copy;
|
||||
}
|
||||
|
@ -691,6 +691,21 @@ pub const Config = struct {
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a shallow copy of this config. This will share all the memory
|
||||
/// allocated with the previous config but will have a new arena for
|
||||
/// any changes or new allocations. The config should have `deinit`
|
||||
/// called when it is complete.
|
||||
///
|
||||
/// Beware: these shallow clones are not meant for a long lifetime,
|
||||
/// they are just meant to exist temporarily for the duration of some
|
||||
/// modifications. It is very important that the original config not
|
||||
/// be deallocated while shallow clones exist.
|
||||
pub fn shallowClone(self: *const Config, alloc_gpa: Allocator) Config {
|
||||
var result = self.*;
|
||||
result._arena = ArenaAllocator.init(alloc_gpa);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Create a copy of this configuration. This is useful as a starting
|
||||
/// point for modifying a configuration since a config can NOT be
|
||||
/// modified once it is in use by an app or surface.
|
||||
|
@ -64,6 +64,9 @@ cols: usize,
|
||||
/// The current scrolling region.
|
||||
scrolling_region: ScrollingRegion,
|
||||
|
||||
/// The last reported pwd, if any.
|
||||
pwd: std.ArrayList(u8),
|
||||
|
||||
/// The charset state
|
||||
charset: CharsetState = .{},
|
||||
|
||||
@ -169,6 +172,7 @@ pub fn init(alloc: Allocator, cols: usize, rows: usize) !Terminal {
|
||||
.top = 0,
|
||||
.bottom = rows - 1,
|
||||
},
|
||||
.pwd = std.ArrayList(u8).init(alloc),
|
||||
};
|
||||
}
|
||||
|
||||
@ -176,6 +180,7 @@ pub fn deinit(self: *Terminal, alloc: Allocator) void {
|
||||
self.tabstops.deinit(alloc);
|
||||
self.screen.deinit();
|
||||
self.secondary_screen.deinit();
|
||||
self.pwd.deinit();
|
||||
self.* = undefined;
|
||||
}
|
||||
|
||||
@ -1501,6 +1506,19 @@ pub fn cursorIsAtPrompt(self: *Terminal) bool {
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Set the pwd for the terminal.
|
||||
pub fn setPwd(self: *Terminal, pwd: []const u8) !void {
|
||||
self.pwd.clearRetainingCapacity();
|
||||
try self.pwd.appendSlice(pwd);
|
||||
}
|
||||
|
||||
/// Returns the pwd for the terminal, if any. The memory is owned by the
|
||||
/// Terminal and is not copied. It is safe until a reset or setPwd.
|
||||
pub fn getPwd(self: *const Terminal) ?[]const u8 {
|
||||
if (self.pwd.items.len == 0) return null;
|
||||
return self.pwd.items;
|
||||
}
|
||||
|
||||
/// Full reset
|
||||
pub fn fullReset(self: *Terminal) void {
|
||||
self.primaryScreen(.{ .clear_on_exit = true, .cursor_save = true });
|
||||
@ -1514,6 +1532,7 @@ pub fn fullReset(self: *Terminal) void {
|
||||
self.screen.selection = null;
|
||||
self.scrolling_region = .{ .top = 0, .bottom = self.rows - 1 };
|
||||
self.previous_char = null;
|
||||
self.pwd.clearRetainingCapacity();
|
||||
}
|
||||
|
||||
test "Terminal: input with no control characters" {
|
||||
|
@ -72,6 +72,16 @@ pub const Command = union(enum) {
|
||||
kind: u8,
|
||||
data: []const u8,
|
||||
},
|
||||
|
||||
/// OSC 7. Reports the current working directory of the shell. This is
|
||||
/// a moderately flawed escape sequence but one that many major terminals
|
||||
/// support so we also support it. To understand the flaws, read through
|
||||
/// this terminal-wg issue: https://gitlab.freedesktop.org/terminal-wg/specifications/-/issues/20
|
||||
report_pwd: struct {
|
||||
/// The reported pwd value. This is not checked for validity. It should
|
||||
/// be a file URL but it is up to the caller to utilize this value.
|
||||
value: []const u8,
|
||||
},
|
||||
};
|
||||
|
||||
pub const Parser = struct {
|
||||
@ -121,6 +131,7 @@ pub const Parser = struct {
|
||||
@"2",
|
||||
@"5",
|
||||
@"52",
|
||||
@"7",
|
||||
|
||||
// We're in a semantic prompt OSC command but we aren't sure
|
||||
// what the command is yet, i.e. `133;`
|
||||
@ -173,6 +184,7 @@ pub const Parser = struct {
|
||||
'1' => self.state = .@"1",
|
||||
'2' => self.state = .@"2",
|
||||
'5' => self.state = .@"5",
|
||||
'7' => self.state = .@"7",
|
||||
else => self.state = .invalid,
|
||||
},
|
||||
|
||||
@ -228,6 +240,17 @@ pub const Parser = struct {
|
||||
else => self.state = .invalid,
|
||||
},
|
||||
|
||||
.@"7" => switch (c) {
|
||||
';' => {
|
||||
self.command = .{ .report_pwd = .{ .value = "" } };
|
||||
|
||||
self.state = .string;
|
||||
self.temp_state = .{ .str = &self.command.report_pwd.value };
|
||||
self.buf_start = self.buf_idx;
|
||||
},
|
||||
else => self.state = .invalid,
|
||||
},
|
||||
|
||||
.@"52" => switch (c) {
|
||||
';' => {
|
||||
self.command = .{ .clipboard_contents = undefined };
|
||||
@ -557,6 +580,30 @@ test "OSC: get/set clipboard" {
|
||||
try testing.expect(std.mem.eql(u8, "?", cmd.clipboard_contents.data));
|
||||
}
|
||||
|
||||
test "OSC: report pwd" {
|
||||
const testing = std.testing;
|
||||
|
||||
var p: Parser = .{};
|
||||
|
||||
const input = "7;file:///tmp/example";
|
||||
for (input) |ch| p.next(ch);
|
||||
|
||||
const cmd = p.end().?;
|
||||
try testing.expect(cmd == .report_pwd);
|
||||
try testing.expect(std.mem.eql(u8, "file:///tmp/example", cmd.report_pwd.value));
|
||||
}
|
||||
|
||||
test "OSC: report pwd empty" {
|
||||
const testing = std.testing;
|
||||
|
||||
var p: Parser = .{};
|
||||
|
||||
const input = "7;";
|
||||
for (input) |ch| p.next(ch);
|
||||
|
||||
try testing.expect(p.end() == null);
|
||||
}
|
||||
|
||||
test "OSC: longer than buffer" {
|
||||
const testing = std.testing;
|
||||
|
||||
|
@ -495,6 +495,12 @@ pub fn Stream(comptime Handler: type) type {
|
||||
} else log.warn("unimplemented OSC callback: {}", .{cmd});
|
||||
},
|
||||
|
||||
.report_pwd => |v| {
|
||||
if (@hasDecl(T, "reportPwd")) {
|
||||
try self.handler.reportPwd(v.value);
|
||||
} else log.warn("unimplemented OSC callback: {}", .{cmd});
|
||||
},
|
||||
|
||||
else => if (@hasDecl(T, "oscUnimplemented"))
|
||||
try self.handler.oscUnimplemented(cmd)
|
||||
else
|
||||
|
@ -614,12 +614,24 @@ const Subprocess = struct {
|
||||
return pty.master;
|
||||
}
|
||||
|
||||
// If we can't access the cwd, then don't set any cwd and inherit.
|
||||
// This is important because our cwd can be set by the shell (OSC 7)
|
||||
// and we don't want to break new windows.
|
||||
const cwd: ?[]const u8 = if (self.cwd) |proposed| cwd: {
|
||||
if (std.fs.accessAbsolute(proposed, .{})) {
|
||||
break :cwd proposed;
|
||||
} else |err| {
|
||||
log.warn("cannot access cwd, ignoring: {}", .{err});
|
||||
break :cwd null;
|
||||
}
|
||||
} else null;
|
||||
|
||||
// Build our subcommand
|
||||
var cmd: Command = .{
|
||||
.path = self.path,
|
||||
.args = self.args,
|
||||
.env = &self.env,
|
||||
.cwd = self.cwd,
|
||||
.cwd = cwd,
|
||||
.stdin = .{ .handle = pty.slave },
|
||||
.stdout = .{ .handle = pty.slave },
|
||||
.stderr = .{ .handle = pty.slave },
|
||||
@ -1249,4 +1261,46 @@ const StreamHandler = struct {
|
||||
pub fn endOfInput(self: *StreamHandler) !void {
|
||||
self.terminal.markSemanticPrompt(.command);
|
||||
}
|
||||
|
||||
pub fn reportPwd(self: *StreamHandler, url: []const u8) !void {
|
||||
const uri = std.Uri.parse(url) catch |e| {
|
||||
log.warn("invalid url in OSC 7: {}", .{e});
|
||||
return;
|
||||
};
|
||||
|
||||
if (!std.mem.eql(u8, "file", uri.scheme) and
|
||||
!std.mem.eql(u8, "kitty-shell-cwd", uri.scheme))
|
||||
{
|
||||
log.warn("OSC 7 scheme must be file, got: {s}", .{uri.scheme});
|
||||
return;
|
||||
}
|
||||
|
||||
// OSC 7 is a little sketchy because anyone can send any value from
|
||||
// any host (such an SSH session). The best practice terminals follow
|
||||
// is to valid the hostname to be local.
|
||||
const host_valid = host_valid: {
|
||||
const host = uri.host orelse break :host_valid false;
|
||||
|
||||
// Empty or localhost is always good
|
||||
if (host.len == 0 or std.mem.eql(u8, "localhost", host)) {
|
||||
break :host_valid true;
|
||||
}
|
||||
|
||||
// Otherwise, it must match our hostname.
|
||||
var buf: [std.os.HOST_NAME_MAX]u8 = undefined;
|
||||
const hostname = std.os.gethostname(&buf) catch |err| {
|
||||
log.warn("failed to get hostname for OSC 7 validation: {}", .{err});
|
||||
break :host_valid false;
|
||||
};
|
||||
|
||||
break :host_valid std.mem.eql(u8, host, hostname);
|
||||
};
|
||||
if (!host_valid) {
|
||||
log.warn("OSC 7 host must be local", .{});
|
||||
return;
|
||||
}
|
||||
|
||||
log.debug("terminal pwd: {s}", .{uri.path});
|
||||
try self.terminal.setPwd(uri.path);
|
||||
}
|
||||
};
|
||||
|
Reference in New Issue
Block a user