diff --git a/.github/scripts/request_review.py b/.github/scripts/request_review.py
deleted file mode 100644
index d799e7c58..000000000
--- a/.github/scripts/request_review.py
+++ /dev/null
@@ -1,189 +0,0 @@
-# /// script
-# requires-python = ">=3.9"
-# dependencies = [
-# "githubkit",
-# "loguru",
-# ]
-# ///
-
-from __future__ import annotations
-
-import asyncio
-import os
-import re
-import sys
-from collections.abc import Iterator
-from contextlib import contextmanager
-from itertools import chain
-
-from githubkit import GitHub
-from githubkit.exception import RequestFailed
-from loguru import logger
-
-ORG_NAME = "ghostty-org"
-REPO_NAME = "ghostty"
-ALLOWED_PARENT_TEAM = "localization"
-LOCALIZATION_TEAM_NAME_PATTERN = re.compile(r"[a-z]{2}_[A-Z]{2}")
-LEVEL_MAP = {"DEBUG": "DBG", "WARNING": "WRN", "ERROR": "ERR"}
-
-logger.remove()
-logger.add(
- sys.stderr,
- format=lambda record: (
- "{time:YYYY-MM-DD HH:mm:ss.SSS} | "
- f"{LEVEL_MAP[record['level'].name]} | "
- "{function}:{line} - "
- "{message}\n"
- ),
- backtrace=True,
- diagnose=True,
-)
-
-
-@contextmanager
-def log_fail(message: str, *, die: bool = True) -> Iterator[None]:
- try:
- yield
- except RequestFailed as exc:
- logger.error(message)
- logger.error(exc)
- logger.error(exc.response.raw_response.json())
- if die:
- sys.exit(1)
-
-
-gh = GitHub(os.environ["GITHUB_TOKEN"])
-
-with log_fail("Invalid token"):
- # Do the simplest request as a test
- gh.rest.rate_limit.get()
-
-
-async def fetch_and_parse_codeowners() -> dict[str, str]:
- logger.debug("Fetching CODEOWNERS file...")
- with log_fail("Failed to fetch CODEOWNERS file"):
- content = (
- await gh.rest.repos.async_get_content(
- ORG_NAME,
- REPO_NAME,
- "CODEOWNERS",
- headers={"Accept": "application/vnd.github.raw+json"},
- )
- ).text
-
- logger.debug("Parsing CODEOWNERS file...")
- codeowners: dict[str, str] = {}
- for line in content.splitlines():
- if not line or line.lstrip().startswith("#"):
- continue
-
- # This assumes that all entries only list one owner
- # and that this owner is a team (ghostty-org/foobar)
- path, owner = line.split()
- path = path.lstrip("/")
- owner = owner.removeprefix(f"@{ORG_NAME}/")
-
- if not is_localization_team(owner):
- logger.debug(f"Skipping non-l11n codeowner {owner!r} for {path}")
- continue
-
- codeowners[path] = owner
- logger.debug(f"Found codeowner {owner!r} for {path}")
- return codeowners
-
-
-async def get_team_members(team_name: str) -> list[str]:
- logger.debug(f"Fetching team {team_name!r}...")
- with log_fail(f"Failed to fetch team {team_name!r}"):
- team = (await gh.rest.teams.async_get_by_name(ORG_NAME, team_name)).parsed_data
-
- if team.parent and team.parent.slug == ALLOWED_PARENT_TEAM:
- logger.debug(f"Fetching team {team_name!r} members...")
- with log_fail(f"Failed to fetch team {team_name!r} members"):
- resp = await gh.rest.teams.async_list_members_in_org(ORG_NAME, team_name)
- members = [m.login for m in resp.parsed_data]
- logger.debug(f"Team {team_name!r} members: {', '.join(members)}")
- return members
-
- logger.warning(f"Team {team_name} does not have a {ALLOWED_PARENT_TEAM!r} parent")
- return []
-
-
-async def get_changed_files(pr_number: int) -> list[str]:
- logger.debug("Gathering changed files...")
- with log_fail("Failed to gather changed files"):
- diff_entries = (
- await gh.rest.pulls.async_list_files(
- ORG_NAME,
- REPO_NAME,
- pr_number,
- per_page=3000,
- headers={"Accept": "application/vnd.github+json"},
- )
- ).parsed_data
- return [d.filename for d in diff_entries]
-
-
-async def request_review(pr_number: int, user: str, pr_author: str) -> None:
- if user == pr_author:
- logger.debug(f"Skipping review request for {user!r} (is PR author)")
- logger.debug(f"Requesting review from {user!r}...")
- with log_fail(f"Failed to request review from {user}", die=False):
- await gh.rest.pulls.async_request_reviewers(
- ORG_NAME,
- REPO_NAME,
- pr_number,
- headers={"Accept": "application/vnd.github+json"},
- data={"reviewers": [user]},
- )
-
-
-def is_localization_team(team_name: str) -> bool:
- return LOCALIZATION_TEAM_NAME_PATTERN.fullmatch(team_name) is not None
-
-
-async def get_pr_author(pr_number: int) -> str:
- logger.debug("Fetching PR author...")
- with log_fail("Failed to fetch PR author"):
- resp = await gh.rest.pulls.async_get(ORG_NAME, REPO_NAME, pr_number)
- pr_author = resp.parsed_data.user.login
- logger.debug(f"Found author: {pr_author!r}")
- return pr_author
-
-
-async def main() -> None:
- logger.debug("Reading PR number...")
- pr_number = int(os.environ["PR_NUMBER"])
- logger.debug(f"Starting review request process for PR #{pr_number}...")
-
- changed_files = await get_changed_files(pr_number)
- logger.debug(f"Changed files: {', '.join(map(repr, changed_files))}")
-
- pr_author = await get_pr_author(pr_number)
- codeowners = await fetch_and_parse_codeowners()
-
- found_owners = set[str]()
- for file in changed_files:
- logger.debug(f"Finding owner for {file!r}...")
- for path, owner in codeowners.items():
- if file.startswith(path):
- logger.debug(f"Found owner: {owner!r}")
- break
- else:
- logger.debug("No owner found")
- continue
- found_owners.add(owner)
-
- member_lists = await asyncio.gather(
- *(get_team_members(owner) for owner in found_owners)
- )
- await asyncio.gather(
- *(
- request_review(pr_number, user, pr_author)
- for user in chain.from_iterable(member_lists)
- )
- )
-
-
-if __name__ == "__main__":
- asyncio.run(main())
diff --git a/.github/workflows/review.yml b/.github/workflows/review.yml
deleted file mode 100644
index 9abe0b5e2..000000000
--- a/.github/workflows/review.yml
+++ /dev/null
@@ -1,37 +0,0 @@
-name: Request Review
-
-on:
- pull_request:
- types:
- - opened
- - synchronize
-
-env:
- PY_COLORS: 1
-
-jobs:
- review:
- runs-on: namespace-profile-ghostty-xsm
- steps:
- - uses: actions/checkout@v4
-
- - name: Setup Cache
- uses: namespacelabs/nscloud-cache-action@v1.2.0
- with:
- path: |
- /nix
- /zig
-
- - uses: cachix/install-nix-action@v30
- with:
- nix_path: nixpkgs=channel:nixos-unstable
- - uses: cachix/cachix-action@v15
- with:
- name: ghostty
- authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
-
- - name: Request Localization Review
- env:
- GITHUB_TOKEN: ${{ secrets.GH_REVIEW_TOKEN }}
- PR_NUMBER: ${{ github.event.pull_request.number }}
- run: nix develop -c uv run .github/scripts/request_review.py
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index be28d50fb..e6e3a77a0 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -374,6 +374,11 @@ jobs:
/zig
- run: sudo apt install -y udev
- run: sudo systemctl start systemd-udevd
+ # Workaround until this is fixed: https://github.com/canonical/lxd-pkg-snap/pull/789
+ - run: |
+ _LXD_SNAP_DEVCGROUP_CONFIG="/var/lib/snapd/cgroup/snap.lxd.device"
+ sudo mkdir -p /var/lib/snapd/cgroup
+ echo 'self-managed=true' | sudo tee "${_LXD_SNAP_DEVCGROUP_CONFIG}"
- uses: snapcore/action-build@v1
with:
path: dist
diff --git a/CODEOWNERS b/CODEOWNERS
index 0665aa407..3d8a4da3d 100644
--- a/CODEOWNERS
+++ b/CODEOWNERS
@@ -144,7 +144,7 @@
# Shell
/src/shell-integration/ @ghostty-org/shell
-/src/termio/shell-integration.zig @ghostty-org/shell
+/src/termio/shell_integration.zig @ghostty-org/shell
# Terminal
/src/simd/ @ghostty-org/terminal
diff --git a/flatpak/com.mitchellh.ghostty.Devel.yml b/flatpak/com.mitchellh.ghostty.Devel.yml
index a939fda9a..244c3987f 100644
--- a/flatpak/com.mitchellh.ghostty.Devel.yml
+++ b/flatpak/com.mitchellh.ghostty.Devel.yml
@@ -1,6 +1,6 @@
app-id: com.mitchellh.ghostty.Devel
runtime: org.gnome.Platform
-runtime-version: "47"
+runtime-version: "48"
sdk: org.gnome.Sdk
sdk-extensions:
- org.freedesktop.Sdk.Extension.ziglang
diff --git a/flatpak/com.mitchellh.ghostty.yml b/flatpak/com.mitchellh.ghostty.yml
index 6f387481c..17c92633f 100644
--- a/flatpak/com.mitchellh.ghostty.yml
+++ b/flatpak/com.mitchellh.ghostty.yml
@@ -1,6 +1,6 @@
app-id: com.mitchellh.ghostty
runtime: org.gnome.Platform
-runtime-version: "47"
+runtime-version: "48"
sdk: org.gnome.Sdk
sdk-extensions:
- org.freedesktop.Sdk.Extension.ziglang
diff --git a/include/ghostty.h b/include/ghostty.h
index f7504eb7e..18c547910 100644
--- a/include/ghostty.h
+++ b/include/ghostty.h
@@ -429,6 +429,13 @@ typedef enum {
GHOSTTY_FULLSCREEN_NON_NATIVE_PADDED_NOTCH,
} ghostty_action_fullscreen_e;
+// apprt.action.FloatWindow
+typedef enum {
+ GHOSTTY_FLOAT_WINDOW_ON,
+ GHOSTTY_FLOAT_WINDOW_OFF,
+ GHOSTTY_FLOAT_WINDOW_TOGGLE,
+} ghostty_action_float_window_e;
+
// apprt.action.SecureInput
typedef enum {
GHOSTTY_SECURE_INPUT_ON,
@@ -610,6 +617,7 @@ typedef enum {
GHOSTTY_ACTION_RENDERER_HEALTH,
GHOSTTY_ACTION_OPEN_CONFIG,
GHOSTTY_ACTION_QUIT_TIMER,
+ GHOSTTY_ACTION_FLOAT_WINDOW,
GHOSTTY_ACTION_SECURE_INPUT,
GHOSTTY_ACTION_KEY_SEQUENCE,
GHOSTTY_ACTION_COLOR_CHANGE,
@@ -638,6 +646,7 @@ typedef union {
ghostty_action_mouse_over_link_s mouse_over_link;
ghostty_action_renderer_health_e renderer_health;
ghostty_action_quit_timer_e quit_timer;
+ ghostty_action_float_window_e float_window;
ghostty_action_secure_input_e secure_input;
ghostty_action_key_sequence_s key_sequence;
ghostty_action_color_change_s color_change;
diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift
index 682099e92..a3a3185d9 100644
--- a/macos/Sources/App/macOS/AppDelegate.swift
+++ b/macos/Sources/App/macOS/AppDelegate.swift
@@ -52,6 +52,8 @@ class AppDelegate: NSObject,
@IBOutlet private var menuSelectSplitLeft: NSMenuItem?
@IBOutlet private var menuSelectSplitRight: NSMenuItem?
@IBOutlet private var menuReturnToDefaultSize: NSMenuItem?
+ @IBOutlet private var menuFloatOnTop: NSMenuItem?
+ @IBOutlet private var menuUseAsDefault: NSMenuItem?
@IBOutlet private var menuIncreaseFontSize: NSMenuItem?
@IBOutlet private var menuDecreaseFontSize: NSMenuItem?
@@ -175,6 +177,12 @@ class AppDelegate: NSObject,
handler: localEventHandler)
// Notifications
+ NotificationCenter.default.addObserver(
+ self,
+ selector: #selector(windowDidBecomeKey),
+ name: NSWindow.didBecomeKeyNotification,
+ object: nil
+ )
NotificationCenter.default.addObserver(
self,
selector: #selector(quickTerminalDidChangeVisibility),
@@ -406,6 +414,7 @@ class AppDelegate: NSObject,
syncMenuShortcut(config, action: "prompt_surface_title", menuItem: self.menuChangeTitle)
syncMenuShortcut(config, action: "toggle_quick_terminal", menuItem: self.menuQuickTerminal)
syncMenuShortcut(config, action: "toggle_visibility", menuItem: self.menuToggleVisibility)
+ syncMenuShortcut(config, action: "toggle_window_float_on_top", menuItem: self.menuFloatOnTop)
syncMenuShortcut(config, action: "inspector:toggle", menuItem: self.menuTerminalInspector)
syncMenuShortcut(config, action: "toggle_command_palette", menuItem: self.menuCommandPalette)
@@ -497,6 +506,10 @@ class AppDelegate: NSObject,
return event
}
+ @objc private func windowDidBecomeKey(_ notification: Notification) {
+ syncFloatOnTopMenu(notification.object as? NSWindow)
+ }
+
@objc private func quickTerminalDidChangeVisibility(_ notification: Notification) {
guard let quickController = notification.object as? QuickTerminalController else { return }
self.menuQuickTerminal?.state = if (quickController.visible) { .on } else { .off }
@@ -899,3 +912,50 @@ class AppDelegate: NSObject,
}
}
}
+
+// MARK: Floating Windows
+
+extension AppDelegate {
+ func syncFloatOnTopMenu(_ window: NSWindow?) {
+ guard let window = (window ?? NSApp.keyWindow) as? TerminalWindow else {
+ // If some other window became key we always turn this off
+ self.menuFloatOnTop?.state = .off
+ return
+ }
+
+ self.menuFloatOnTop?.state = window.level == .floating ? .on : .off
+ }
+
+ @IBAction func floatOnTop(_ menuItem: NSMenuItem) {
+ menuItem.state = menuItem.state == .on ? .off : .on
+ guard let window = NSApp.keyWindow else { return }
+ window.level = menuItem.state == .on ? .floating : .normal
+ }
+
+ @IBAction func useAsDefault(_ sender: NSMenuItem) {
+ let ud = UserDefaults.standard
+ let key = TerminalWindow.defaultLevelKey
+ if (menuFloatOnTop?.state == .on) {
+ ud.set(NSWindow.Level.floating, forKey: key)
+ } else {
+ ud.removeObject(forKey: key)
+ }
+ }
+}
+
+// MARK: NSMenuItemValidation
+
+extension AppDelegate: NSMenuItemValidation {
+ func validateMenuItem(_ item: NSMenuItem) -> Bool {
+ switch item.action {
+ case #selector(floatOnTop(_:)),
+ #selector(useAsDefault(_:)):
+ // Float on top items only active if the key window is a primary
+ // terminal window (not quick terminal).
+ return NSApp.keyWindow is TerminalWindow
+
+ default:
+ return true
+ }
+ }
+}
diff --git a/macos/Sources/App/macOS/MainMenu.xib b/macos/Sources/App/macOS/MainMenu.xib
index 8f7b16aa9..724f21355 100644
--- a/macos/Sources/App/macOS/MainMenu.xib
+++ b/macos/Sources/App/macOS/MainMenu.xib
@@ -25,6 +25,7 @@
+
@@ -56,6 +57,7 @@
+
@@ -402,6 +404,19 @@
+
+
+