Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions include/ghostty.h
Original file line number Diff line number Diff line change
Expand Up @@ -980,6 +980,7 @@ typedef enum {
GHOSTTY_ACTION_SEARCH_SELECTED,
GHOSTTY_ACTION_READONLY,
GHOSTTY_ACTION_COPY_TITLE_TO_CLIPBOARD,
GHOSTTY_ACTION_PROMPT_READY,
} ghostty_action_tag_e;

typedef union {
Expand Down
10 changes: 10 additions & 0 deletions src/Surface.zig
Original file line number Diff line number Diff line change
Expand Up @@ -1189,6 +1189,16 @@ pub fn handleMessage(self: *Surface, msg: Message) !void {
};
},

.prompt_input => {
_ = self.rt_app.performAction(
.{ .surface = self },
.prompt_ready,
{},
) catch |err| {
log.warn("apprt failed to notify prompt ready={}", .{err});
};
},

.search_total => |v| {
_ = try self.rt_app.performAction(
.{ .surface = self },
Expand Down
4 changes: 4 additions & 0 deletions src/apprt/action.zig
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,9 @@ pub const Action = union(Key) {
/// otherwise the terminal-set title.
copy_title_to_clipboard,

/// The shell prompt became interactive (OSC 133;B). Payload-less.
prompt_ready,

/// Sync with: ghostty_action_tag_e
pub const Key = enum(c_int) {
quit,
Expand Down Expand Up @@ -410,6 +413,7 @@ pub const Action = union(Key) {
search_selected,
readonly,
copy_title_to_clipboard,
prompt_ready,

test "ghostty.h Action.Key" {
try lib.checkGhosttyHEnum(Key, "GHOSTTY_ACTION_");
Expand Down
4 changes: 4 additions & 0 deletions src/apprt/surface.zig
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,10 @@ pub const Message = union(enum) {
/// of the command.
stop_command: ?u8,

/// The shell prompt is now interactive (OSC 133;B / input-start).
/// Forwarded to the apprt as the `prompt_ready` action.
prompt_input,

/// The scrollbar state changed for the surface.
scrollbar: terminal.Scrollbar,

Expand Down
2 changes: 2 additions & 0 deletions src/config/Config.zig
Original file line number Diff line number Diff line change
Expand Up @@ -8782,9 +8782,11 @@ pub const ShellIntegration = enum {
none,
detect,
bash,
cmd,
elvish,
fish,
nushell,
powershell,
zsh,
};

Expand Down
2 changes: 2 additions & 0 deletions src/termio/Exec.zig
Original file line number Diff line number Diff line change
Expand Up @@ -948,9 +948,11 @@ const Subprocess = struct {

.detect => null,
.bash => .bash,
.cmd => .cmd,
.elvish => .elvish,
.fish => .fish,
.nushell => .nushell,
.powershell => .powershell,
.zsh => .zsh,
};

Expand Down
171 changes: 160 additions & 11 deletions src/termio/shell_integration.zig
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const log = std.log.scoped(.shell_integration);
/// Shell types we support
pub const Shell = enum {
bash,
cmd,
elvish,
fish,
nushell,
Expand Down Expand Up @@ -80,6 +81,8 @@ pub fn setup(
env,
),

.cmd => try setupCmd(alloc_arena, command, env),

.elvish, .fish => xdg: {
if (!try setupXdgDataDirs(alloc_arena, resource_dir, env)) return null;
break :xdg try command.clone(alloc_arena);
Expand Down Expand Up @@ -182,6 +185,7 @@ fn detectShell(alloc: Allocator, command: config.Command) !?Shell {
exe;
if (std.ascii.eqlIgnoreCase("pwsh", exe_no_ext)) return .powershell;
if (std.ascii.eqlIgnoreCase("powershell", exe_no_ext)) return .powershell;
if (std.ascii.eqlIgnoreCase("cmd", exe_no_ext)) return .cmd;

return null;
}
Expand All @@ -208,6 +212,9 @@ test detectShell {
try testing.expectEqual(.powershell, try detectShell(alloc, .{ .shell = "pwsh.exe" }));
try testing.expectEqual(.powershell, try detectShell(alloc, .{ .shell = "powershell" }));
try testing.expectEqual(.powershell, try detectShell(alloc, .{ .shell = "powershell.exe" }));
try testing.expectEqual(.cmd, try detectShell(alloc, .{ .shell = "cmd" }));
try testing.expectEqual(.cmd, try detectShell(alloc, .{ .shell = "cmd.exe" }));
try testing.expectEqual(.cmd, try detectShell(alloc, .{ .shell = "CMD.EXE" }));

// std.fs.path.basename uses POSIX semantics on non-Windows hosts,
// so a backslash-only path is treated as a single component. Only
Expand Down Expand Up @@ -922,12 +929,23 @@ test "nushell: missing resources" {
try testing.expectEqual(0, env.count());
}

/// Setup PowerShell shell integration. PowerShell has no equivalent
/// of bash's `ENV` or zsh's `ZDOTDIR` to auto-source a script, so we
/// only export the absolute path to our integration script via the
/// `GHOSTTY_SHELL_INTEGRATION_PS1` environment variable. Users opt in
/// by adding a one-liner to their `$PROFILE` that dot-sources it.
/// We do not modify the command line; users opt in via their $PROFILE.
/// Setup PowerShell shell integration. PowerShell has no equivalent of
/// bash's `ENV` or zsh's `ZDOTDIR` to auto-source a script, so we always
/// export the absolute path to our integration script via the
/// `GHOSTTY_SHELL_INTEGRATION_PS1` environment variable. Users can opt in
/// manually by dot-sourcing that path from their `$PROFILE`.
///
/// For a bare interactive shell (the user configured just `pwsh` with no
/// arguments of their own) we go further and rewrite the launch command to
/// auto-source the script:
///
/// <pwsh> -NoExit -Command ". '<resource_dir>/.../ghostty.ps1'"
///
/// PowerShell still loads the user's `$PROFILE` first, then runs the
/// `-Command`, which dot-sources our script. The script wraps the
/// now-final prompt and emits the OSC 133 marks. If the user supplied
/// their own command or arguments we leave the command untouched so we
/// never clobber their invocation (the env var fallback still works).
fn setupPowerShell(
alloc_arena: Allocator,
command: config.Command,
Expand All @@ -937,16 +955,47 @@ fn setupPowerShell(
// Use forward slashes for path composition to match the style of the
// other shell-integration setup functions. PowerShell on Windows
// accepts forward slashes in paths just fine.
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
const script_path = try std.fmt.bufPrint(
&path_buf,
const script_path = try std.fmt.allocPrint(
alloc_arena,
"{s}/shell-integration/powershell/ghostty.ps1",
.{resource_dir},
);

try env.put("GHOSTTY_SHELL_INTEGRATION_PS1", script_path);

return try command.clone(alloc_arena);
// Inspect the configured command. We auto-inject only when it's a
// bare interactive shell: argv is exactly the executable with no
// user-supplied arguments. Anything else (e.g. `pwsh -NoLogo` or a
// `-Command`/`-File` invocation) is left alone so we don't override
// what the user asked for.
var iter = try command.argIterator(alloc_arena);
defer iter.deinit();

const exe = iter.next() orelse return null;
// A second argument means the user provided their own command line.
if (iter.next() != null) return try command.clone(alloc_arena);

// Build the dot-source command. Single quotes keep the path literal
// for PowerShell even if it contains spaces; this assumes resource_dir
// (Ghostty-owned) never contains a single quote, which PowerShell would
// otherwise require doubled. We emit a `.direct` command so the
// `-Command` payload survives downstream argv parsing as a single
// argument (a `.shell` string would be re-split on spaces, breaking the
// dot-source expression).
const dot_source = try std.fmt.allocPrintSentinel(
alloc_arena,
". '{s}'",
.{script_path},
0,
);

const argv = try alloc_arena.alloc([:0]const u8, 4);
argv[0] = try alloc_arena.dupeZ(u8, exe);
argv[1] = try alloc_arena.dupeZ(u8, "-NoExit");
argv[2] = try alloc_arena.dupeZ(u8, "-Command");
argv[3] = dot_source;

return .{ .direct = argv };
}

test "powershell" {
Expand All @@ -962,8 +1011,17 @@ test "powershell" {
var env = EnvMap.init(alloc);
defer env.deinit();

// A bare `pwsh` is rewritten to auto-source the integration script.
const command = try setupPowerShell(alloc, .{ .shell = "pwsh" }, res.path, &env);
try testing.expectEqualStrings("pwsh", command.?.shell);
try testing.expect(command.? == .direct);
const argv = command.?.direct;
try testing.expectEqual(@as(usize, 4), argv.len);
try testing.expectEqualStrings("pwsh", argv[0]);
try testing.expectEqualStrings("-NoExit", argv[1]);
try testing.expectEqualStrings("-Command", argv[2]);
// The dot-source argument references our integration script.
try testing.expect(std.mem.indexOf(u8, argv[3], "ghostty.ps1") != null);
try testing.expect(std.mem.startsWith(u8, argv[3], ". '"));

var path_buf: [std.fs.max_path_bytes]u8 = undefined;
try testing.expectEqualStrings(
Expand All @@ -972,6 +1030,97 @@ test "powershell" {
);
}

test "powershell: user-supplied args are left untouched" {
const testing = std.testing;

var arena = ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();

var res: TmpResourcesDir = try .init(alloc, .powershell);
defer res.deinit();

var env = EnvMap.init(alloc);
defer env.deinit();

// The user gave their own arguments, so we don't rewrite the command;
// we only export the opt-in env var.
const command = try setupPowerShell(
alloc,
.{ .shell = "pwsh -NoLogo -NoProfile" },
res.path,
&env,
);
try testing.expect(command.? == .shell);
try testing.expectEqualStrings("pwsh -NoLogo -NoProfile", command.?.shell);

var path_buf: [std.fs.max_path_bytes]u8 = undefined;
try testing.expectEqualStrings(
try std.fmt.bufPrint(&path_buf, "{s}/ghostty.ps1", .{res.shell_path}),
env.get("GHOSTTY_SHELL_INTEGRATION_PS1").?,
);
}

/// Setup cmd.exe shell integration. cmd has no rc file or pre/post-exec
/// hooks, but it re-expands the PROMPT env var on every prompt and supports
/// `$e` (ESC) on Windows 10+. We wrap the prompt body in OSC 133;A / 133;B
/// (prompt-start / input-start) and report cwd via OSC 9;9. No command
/// start/end (C/D) marks are possible. Unlike the script-based shells, cmd
/// has no way to read GHOSTTY_SHELL_FEATURES at runtime, so these marks are
/// always emitted (the gated features do not apply to cmd anyway).
fn setupCmd(
alloc_arena: Allocator,
command: config.Command,
env: *EnvMap,
) !?config.Command {
// Preserve the user's prompt body if set, else cmd's default `$p$g`.
const body = env.get("PROMPT") orelse "$p$g";
// `$e` = ESC, terminator ST = `$e\`. OSC 9;9 carries cwd via `$p`.
const wrapped = try std.fmt.allocPrint(
alloc_arena,
"$e]133;A$e\\$e]9;9;$p$e\\{s}$e]133;B$e\\",
.{body},
);
try env.put("PROMPT", wrapped);
return try command.clone(alloc_arena);
}

test "cmd: PROMPT carries OSC 133 marks" {
const testing = std.testing;
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();

var env = EnvMap.init(alloc);
defer env.deinit();

_ = try setupCmd(alloc, .{ .shell = "cmd.exe" }, &env);

const prompt = env.get("PROMPT") orelse return error.NoPrompt;
try testing.expect(std.mem.indexOf(u8, prompt, "133;A") != null);
try testing.expect(std.mem.indexOf(u8, prompt, "133;B") != null);
try testing.expect(std.mem.indexOf(u8, prompt, "9;9") != null);
}

test "cmd: preserves existing PROMPT body" {
const testing = std.testing;
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();

var env = EnvMap.init(alloc);
defer env.deinit();

try env.put("PROMPT", "$p$g$s");

_ = try setupCmd(alloc, .{ .shell = "cmd.exe" }, &env);

const prompt = env.get("PROMPT") orelse return error.NoPrompt;
try testing.expect(std.mem.indexOf(u8, prompt, "$p$g$s") != null);
try testing.expect(std.mem.indexOf(u8, prompt, "133;A") != null);
try testing.expect(std.mem.indexOf(u8, prompt, "133;B") != null);
}

/// Setup the zsh automatic shell integration. This works by setting
/// ZDOTDIR to our resources dir so that zsh will load our config. This
/// config then loads the true user config.
Expand Down
5 changes: 4 additions & 1 deletion src/termio/stream_handler.zig
Original file line number Diff line number Diff line change
Expand Up @@ -1175,8 +1175,11 @@ pub const StreamHandler = struct {
self.surfaceMessageWriter(.{ .stop_command = code });
},

.end_prompt_start_input => {
self.surfaceMessageWriter(.prompt_input);
},

// Handled by Terminal, no special handling by us
.end_prompt_start_input,
.end_prompt_start_input_terminate_eol,
.fresh_line,
.fresh_line_new_prompt,
Expand Down
1 change: 1 addition & 0 deletions windows/Ghostty.Core/Interop/GhosttyActions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ internal enum GhosttyActionTag
EndSearch = 60,
SearchTotal = 61,
SearchSelected = 62,
PromptReady = 65,
}

// ghostty_action_scrollbar_s:
Expand Down
1 change: 1 addition & 0 deletions windows/Ghostty.Tests/Interop/GhosttyActionsLayoutTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ public class GhosttyActionsLayoutTests
[InlineData((int)GhosttyActionTag.EndSearch, 60)]
[InlineData((int)GhosttyActionTag.SearchTotal, 61)]
[InlineData((int)GhosttyActionTag.SearchSelected, 62)]
[InlineData((int)GhosttyActionTag.PromptReady, 65)]
public void ActionTag_Ordinal_Matches_Upstream(int tag, int expected)
{
Assert.Equal(expected, tag);
Expand Down
6 changes: 6 additions & 0 deletions windows/Ghostty/Controls/TerminalControl.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,7 @@ internal void RaiseProgressChanged(Ghostty.Core.Tabs.TabProgressState state)
CurrentProgress = state;
ProgressChanged?.Invoke(this, state);
}
internal void RaisePromptReady() => PromptReady?.Invoke(this, EventArgs.Empty);

// Called on the libghostty thread. Stashes the latest state and
// enqueues a single UI-thread flush. Coalescing: if libghostty
Expand Down Expand Up @@ -288,6 +289,10 @@ private void OnScrollBarPointerWheelChanged(object sender, PointerRoutedEventArg
public event EventHandler? CloseRequested;
internal event EventHandler<Ghostty.Core.Tabs.TabProgressState>? ProgressChanged;

/// <summary>Raised when the shell prompt becomes interactive (OSC 133;B).
/// The first such event per surface marks the shell as responsive.</summary>
public event EventHandler? PromptReady;

public TerminalControl()
{
InitializeComponent();
Expand Down Expand Up @@ -466,6 +471,7 @@ internal void DisposeSurface()
CloseRequested = null;
HoveredLinkChanged = null;
ProgressChanged = null;
PromptReady = null;
}

private static IntPtr AllocEmptyUtf8()
Expand Down
10 changes: 10 additions & 0 deletions windows/Ghostty/Hosting/GhosttyHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -628,6 +628,16 @@ private byte OnAction(GhosttyApp _, IntPtr targetPtr, IntPtr actionPtr)
return 1;
}

case GhosttyActionTag.PromptReady:
{
_dispatcher.TryEnqueue(() =>
{
if (TryResolveControl(surfaceHandle, out var c) && c is not null)
c.RaisePromptReady();
});
return 1;
}

case GhosttyActionTag.ProgressReport:
{
var state = (GhosttyProgressState)Marshal.ReadInt32(actionPtr, 8);
Expand Down