From ee24133cf4eea98e8a0795ea71a62cd2c2436795 Mon Sep 17 00:00:00 2001 From: Alessandro De Blasis Date: Sat, 30 May 2026 18:01:59 +0200 Subject: [PATCH 1/7] apprt: add prompt_ready action (OSC 133;B) --- include/ghostty.h | 1 + src/apprt/action.zig | 4 ++++ src/apprt/surface.zig | 4 ++++ 3 files changed, 9 insertions(+) diff --git a/include/ghostty.h b/include/ghostty.h index f80bb8bd218..5ee5ce35695 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -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 { diff --git a/src/apprt/action.zig b/src/apprt/action.zig index f6865af83dc..9a1c72a335b 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -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, @@ -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_"); diff --git a/src/apprt/surface.zig b/src/apprt/surface.zig index 3cb0016fadf..98e6eec14db 100644 --- a/src/apprt/surface.zig +++ b/src/apprt/surface.zig @@ -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, From 73601fa253024a95f8cec1d1f1e857f29f770d68 Mon Sep 17 00:00:00 2001 From: Alessandro De Blasis Date: Sat, 30 May 2026 18:15:02 +0200 Subject: [PATCH 2/7] core: dispatch prompt_ready on OSC 133;B --- src/Surface.zig | 10 ++++++++++ src/termio/stream_handler.zig | 5 ++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/Surface.zig b/src/Surface.zig index 2b3f448dc09..8f8da5f0e17 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -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 }, diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index a1f995708ea..777c2d22af7 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -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, From 1095005c5d1e21d015d06b551010cbef1f3e5134 Mon Sep 17 00:00:00 2001 From: Alessandro De Blasis Date: Sat, 30 May 2026 18:17:50 +0200 Subject: [PATCH 3/7] windows: surface prompt_ready as TerminalControl.PromptReady --- windows/Ghostty.Core/Interop/GhosttyActions.cs | 1 + .../Ghostty.Tests/Interop/GhosttyActionsLayoutTests.cs | 1 + windows/Ghostty/Controls/TerminalControl.xaml.cs | 6 ++++++ windows/Ghostty/Hosting/GhosttyHost.cs | 10 ++++++++++ 4 files changed, 18 insertions(+) diff --git a/windows/Ghostty.Core/Interop/GhosttyActions.cs b/windows/Ghostty.Core/Interop/GhosttyActions.cs index 9054306c71e..fe979636a65 100644 --- a/windows/Ghostty.Core/Interop/GhosttyActions.cs +++ b/windows/Ghostty.Core/Interop/GhosttyActions.cs @@ -35,6 +35,7 @@ internal enum GhosttyActionTag EndSearch = 60, SearchTotal = 61, SearchSelected = 62, + PromptReady = 65, } // ghostty_action_scrollbar_s: diff --git a/windows/Ghostty.Tests/Interop/GhosttyActionsLayoutTests.cs b/windows/Ghostty.Tests/Interop/GhosttyActionsLayoutTests.cs index 9805e77c405..6ada89b0dce 100644 --- a/windows/Ghostty.Tests/Interop/GhosttyActionsLayoutTests.cs +++ b/windows/Ghostty.Tests/Interop/GhosttyActionsLayoutTests.cs @@ -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); diff --git a/windows/Ghostty/Controls/TerminalControl.xaml.cs b/windows/Ghostty/Controls/TerminalControl.xaml.cs index 8d097cc8f28..ca71def8474 100644 --- a/windows/Ghostty/Controls/TerminalControl.xaml.cs +++ b/windows/Ghostty/Controls/TerminalControl.xaml.cs @@ -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 @@ -288,6 +289,10 @@ private void OnScrollBarPointerWheelChanged(object sender, PointerRoutedEventArg public event EventHandler? CloseRequested; internal event EventHandler? ProgressChanged; + /// Raised when the shell prompt becomes interactive (OSC 133;B). + /// The first such event per surface marks the shell as responsive. + public event EventHandler? PromptReady; + public TerminalControl() { InitializeComponent(); @@ -466,6 +471,7 @@ internal void DisposeSurface() CloseRequested = null; HoveredLinkChanged = null; ProgressChanged = null; + PromptReady = null; } private static IntPtr AllocEmptyUtf8() diff --git a/windows/Ghostty/Hosting/GhosttyHost.cs b/windows/Ghostty/Hosting/GhosttyHost.cs index c46acc0ab65..0cefb116f38 100644 --- a/windows/Ghostty/Hosting/GhosttyHost.cs +++ b/windows/Ghostty/Hosting/GhosttyHost.cs @@ -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); From 7893ba6501950ef2ed7dd91fb01a4079084adead Mon Sep 17 00:00:00 2001 From: Alessandro De Blasis Date: Sat, 30 May 2026 18:34:46 +0200 Subject: [PATCH 4/7] shell-integration: auto-inject PowerShell via launch args --- src/termio/shell_integration.zig | 102 +++++++++++++++++++++++++++---- 1 file changed, 91 insertions(+), 11 deletions(-) diff --git a/src/termio/shell_integration.zig b/src/termio/shell_integration.zig index 202e74b0fa7..c8b1b3b3391 100644 --- a/src/termio/shell_integration.zig +++ b/src/termio/shell_integration.zig @@ -922,12 +922,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: +/// +/// -NoExit -Command ". '/.../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, @@ -937,16 +948,45 @@ 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. 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" { @@ -962,8 +1002,48 @@ 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( + try std.fmt.bufPrint(&path_buf, "{s}/ghostty.ps1", .{res.shell_path}), + env.get("GHOSTTY_SHELL_INTEGRATION_PS1").?, + ); +} + +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( From 231d0c50a598544e8f2e7195439274298ea11942 Mon Sep 17 00:00:00 2001 From: Alessandro De Blasis Date: Sat, 30 May 2026 18:55:11 +0200 Subject: [PATCH 5/7] shell-integration: cmd via PROMPT (OSC 133 A/B + cwd) --- src/config/Config.zig | 1 + src/termio/Exec.zig | 1 + src/termio/shell_integration.zig | 65 ++++++++++++++++++++++++++++++++ 3 files changed, 67 insertions(+) diff --git a/src/config/Config.zig b/src/config/Config.zig index 2b041dc761e..474375d631d 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -8782,6 +8782,7 @@ pub const ShellIntegration = enum { none, detect, bash, + cmd, elvish, fish, nushell, diff --git a/src/termio/Exec.zig b/src/termio/Exec.zig index 8e073baff64..02e4d9bddca 100644 --- a/src/termio/Exec.zig +++ b/src/termio/Exec.zig @@ -948,6 +948,7 @@ const Subprocess = struct { .detect => null, .bash => .bash, + .cmd => .cmd, .elvish => .elvish, .fish => .fish, .nushell => .nushell, diff --git a/src/termio/shell_integration.zig b/src/termio/shell_integration.zig index c8b1b3b3391..21a833b3cf8 100644 --- a/src/termio/shell_integration.zig +++ b/src/termio/shell_integration.zig @@ -12,6 +12,7 @@ const log = std.log.scoped(.shell_integration); /// Shell types we support pub const Shell = enum { bash, + cmd, elvish, fish, nushell, @@ -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); @@ -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; } @@ -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 @@ -1052,6 +1059,64 @@ test "powershell: user-supplied args are left untouched" { ); } +/// 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. +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. From fcfcf2f8e9dc754a898b4db5d0d0b5a3ab43ea0e Mon Sep 17 00:00:00 2001 From: Alessandro De Blasis Date: Sat, 30 May 2026 19:07:15 +0200 Subject: [PATCH 6/7] config: allow explicit shell-integration = powershell --- src/config/Config.zig | 1 + src/termio/Exec.zig | 1 + 2 files changed, 2 insertions(+) diff --git a/src/config/Config.zig b/src/config/Config.zig index 474375d631d..945ce9adf6c 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -8786,6 +8786,7 @@ pub const ShellIntegration = enum { elvish, fish, nushell, + powershell, zsh, }; diff --git a/src/termio/Exec.zig b/src/termio/Exec.zig index 02e4d9bddca..19801f13352 100644 --- a/src/termio/Exec.zig +++ b/src/termio/Exec.zig @@ -952,6 +952,7 @@ const Subprocess = struct { .elvish => .elvish, .fish => .fish, .nushell => .nushell, + .powershell => .powershell, .zsh => .zsh, }; From 8d8f0048e9dc3fb073cb18a14d4e4ae5ea2c2047 Mon Sep 17 00:00:00 2001 From: Alessandro De Blasis Date: Sat, 30 May 2026 21:50:44 +0200 Subject: [PATCH 7/7] review: document cmd feature gap + pwsh quote assumption; align action enum --- src/termio/shell_integration.zig | 14 +++++++++----- windows/Ghostty.Core/Interop/GhosttyActions.cs | 2 +- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/termio/shell_integration.zig b/src/termio/shell_integration.zig index 21a833b3cf8..872ec926d25 100644 --- a/src/termio/shell_integration.zig +++ b/src/termio/shell_integration.zig @@ -976,10 +976,12 @@ fn setupPowerShell( 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. 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). + // 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}'", @@ -1063,7 +1065,9 @@ test "powershell: user-supplied args are left untouched" { /// 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. +/// 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, diff --git a/windows/Ghostty.Core/Interop/GhosttyActions.cs b/windows/Ghostty.Core/Interop/GhosttyActions.cs index fe979636a65..6d783a6261b 100644 --- a/windows/Ghostty.Core/Interop/GhosttyActions.cs +++ b/windows/Ghostty.Core/Interop/GhosttyActions.cs @@ -35,7 +35,7 @@ internal enum GhosttyActionTag EndSearch = 60, SearchTotal = 61, SearchSelected = 62, - PromptReady = 65, + PromptReady = 65, } // ghostty_action_scrollbar_s: