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
14 changes: 14 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ jobs:

- name: Install system dependencies
run: |
# Chrome is not a build dependency; its index can block apt updates.
sudo rm -f /etc/apt/sources.list.d/google-chrome.list /etc/apt/sources.list.d/google-chrome.sources
sudo apt-get update
sudo apt-get install -y \
libgtk-4-dev \
Expand All @@ -35,6 +37,8 @@ jobs:
libonig-dev \
xdotool \
x11-xkb-utils \
openbox \
zsh \
xvfb

- name: Build
Expand All @@ -59,6 +63,16 @@ jobs:
env:
SEANCE_TEST_BINARY: ${{ github.workspace }}/zig-out/bin/seance

- name: Fullscreen tests
run: python3 -m unittest discover -s tests -p 'test_fullscreen.py' -v
env:
SEANCE_TEST_BINARY: ${{ github.workspace }}/zig-out/bin/seance

- name: Shell integration tests
run: xvfb-run -a python3 -m unittest discover -s tests -p 'test_shell_integration.py' -v
env:
SEANCE_TEST_BINARY: ${{ github.workspace }}/zig-out/bin/seance

- name: IME tests
run: python3 -m unittest discover -s tests -p 'test_ime.py' -v
env:
Expand Down
9 changes: 9 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,15 @@ Set `SEANCE_TEST_CODEX` to the real Codex binary (outside Séance's wrapper dire
to also check hook loading and approval persistence using its local app-server.
This check uses a temporary Codex home and makes no model calls.

Fullscreen tests require Openbox, Xvfb, xdotool, setxkbmap, a C compiler, and
libadwaita headers. They use a separate X server and window manager. Shell
integration tests require zsh and check user startup files and both sets of hooks:

```bash
SEANCE_TEST_BINARY="$PWD/zig-out/bin/seance" python3 -m unittest discover -s tests -p 'test_fullscreen.py' -v
SEANCE_TEST_BINARY="$PWD/zig-out/bin/seance" xvfb-run -a python3 -m unittest discover -s tests -p 'test_shell_integration.py' -v
```

Physical keyboard remap tests require `Xvfb`, `xdotool`, and `setxkbmap`.
They create a separate X server and never modify your desktop's keyboard layout:

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ A bundled [skill file](skills/seance-skill.md) provides AI agents with a complet

### And also

Workspaces, session persistence across restarts, tabs within columns, a command palette, focus-follows-mouse, and no telemetry.
Workspaces, session persistence across restarts, tabs within columns, a command palette, focus-follows-mouse, and no telemetry. Press **F11** to toggle fullscreen, or choose **Toggle Fullscreen** in the command palette. The header bar hides in fullscreen and returns when you exit.

## Installation

Expand Down
4 changes: 2 additions & 2 deletions build.zig
Original file line number Diff line number Diff line change
Expand Up @@ -205,14 +205,14 @@ pub fn build(b: *std.Build) void {
b.installDirectory(.{
.source_dir = b.path("resources/shell-integration"),
.install_dir = .prefix,
.install_subdir = "share/shell-integration",
.install_subdir = "share/seance/shell-integration",
});

// Install wrapper scripts (claude wrapper, etc.)
b.installDirectory(.{
.source_dir = b.path("resources/bin"),
.install_dir = .prefix,
.install_subdir = "share/bin",
.install_subdir = "share/seance/bin",
});

// Install bundled icons (from GNOME Icon Library) into hicolor theme
Expand Down
1 change: 1 addition & 0 deletions src/command_palette.zig
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ const static_commands = [_]Command{
.{ .label = "Jump to Unread", .action = .jump_to_unread, .keywords = "jump unread notification" },
.{ .label = "Open Settings", .action = .open_settings, .keywords = "settings preferences config options" },
.{ .label = "Reload Configuration", .action = .reload_config, .keywords = "config reload refresh settings" },
.{ .label = "Toggle Fullscreen", .action = .toggle_fullscreen, .keywords = "fullscreen toggle window maximize f11" },
.{ .label = "Quit Seance", .action = .quit_app, .keywords = "quit exit close application app shutdown" },

// Workspace commands
Expand Down
12 changes: 12 additions & 0 deletions src/ghostty_bridge.zig
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,18 @@ fn createWrapperResourcesDir() bool {
}
}

// Copy .zshenv rather than symlinking it: zsh's :A modifier resolves
// symlinks when this bootstrap locates its sibling ghostty-integration.
// It must find our wrapper so both Ghostty and Seance hooks are loaded.
{
var zbuf: [std.fs.max_path_bytes]u8 = undefined;
const source = std.fmt.bufPrint(&zbuf, "{s}/shell-integration/zsh/.zshenv", .{real}) catch return false;
std.Io.Dir.cwd().copyFile(source, wd, "shell-integration/zsh/.zshenv", io.get(), .{}) catch |e| {
std.log.warn("ghostty_bridge: failed to copy .zshenv: {}", .{e});
return false;
};
}

// Write wrapper shell integration scripts
writeShellWrapper(wd, "shell-integration/bash/ghostty.bash", real, "/shell-integration/bash/ghostty.bash", "/bash-integration.sh") catch return false;
writeShellWrapper(wd, "shell-integration/zsh/ghostty-integration", real, "/shell-integration/zsh/ghostty-integration", "/zsh-integration.sh") catch return false;
Expand Down
3 changes: 3 additions & 0 deletions src/keybinds.zig
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ pub const Action = enum(u8) {

// Window
new_window,
toggle_fullscreen,
quit_app,

// Command palette
Expand Down Expand Up @@ -260,6 +261,7 @@ fn initDefaults() void {

// Window
set(.new_window, .{ .key = c.GDK_KEY_N, .ctrl = true, .shift = true });
set(.toggle_fullscreen, .{ .key = c.GDK_KEY_F11 });
set(.quit_app, .{ .key = c.GDK_KEY_Q, .ctrl = true, .shift = true });

// Command palette
Expand Down Expand Up @@ -547,6 +549,7 @@ pub fn executeAction(action: Action, state: *Window.WindowState) c.gboolean {
.new_window => {
_ = state.window_manager.newWindow();
},
.toggle_fullscreen => state.toggleFullscreen(),
.quit_app => state.quitApp(),

// Command palette
Expand Down
4 changes: 2 additions & 2 deletions src/pane.zig
Original file line number Diff line number Diff line change
Expand Up @@ -707,10 +707,10 @@ fn initSurface(pane: *Pane, width: u32, height: u32) void {
if (io.executablePath(&exe_buf)) |exe_path| {
if (std.fs.path.dirname(exe_path)) |exe_dir| {
if (std.fs.path.dirname(exe_dir)) |prefix| {
if (std.fmt.bufPrintZ(&int_env_val_buf, "{s}/share/shell-integration", .{prefix})) |iv| {
if (std.fmt.bufPrintZ(&int_env_val_buf, "{s}/share/seance/shell-integration", .{prefix})) |iv| {
int_val = iv;
} else |_| {}
if (std.fmt.bufPrintZ(&bin_env_val_buf, "{s}/share/bin", .{prefix})) |bv| {
if (std.fmt.bufPrintZ(&bin_env_val_buf, "{s}/share/seance/bin", .{prefix})) |bv| {
bin_val = bv;
} else |_| {}
}
Expand Down
1 change: 1 addition & 0 deletions src/shortcuts_overlay.zig
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ const groups = [_]Group{
.title = "General",
.rows = &.{
.{ .single = .{ .action = .new_window, .label = "New Window" } },
.{ .single = .{ .action = .toggle_fullscreen, .label = "Fullscreen" } },
.{ .single = .{ .action = .open_command_palette, .label = "Command Palette" } },
.{ .single = .{ .action = .open_folder, .label = "Open Folder" } },
.{ .single = .{ .action = .open_settings, .label = "Settings" } },
Expand Down
31 changes: 31 additions & 0 deletions src/window.zig
Original file line number Diff line number Diff line change
Expand Up @@ -805,6 +805,15 @@ pub const WindowState = struct {
self.closeWorkspace(self.active_workspace);
}

pub fn toggleFullscreen(self: *WindowState) void {
const win: *c.GtkWindow = @ptrCast(self.gtk_window);
if (c.gtk_window_is_fullscreen(win) != 0) {
c.gtk_window_unfullscreen(win);
} else {
c.gtk_window_fullscreen(win);
}
}

/// Quit the entire application. Routes through g_application_quit so
/// onShutdown saves all currently-open windows (with scrollback). If
/// `confirm_close_window` is enabled and there's interesting state to
Expand Down Expand Up @@ -1197,11 +1206,18 @@ pub const WindowState = struct {
c.adw_application_window_set_content(@ptrCast(window), toolbar_view);

self.toolbar_view = @ptrCast(toolbar_view);
self.syncFullscreenChrome();

// Push any existing unread count into the fresh badge.
self.sidebar.refresh();
}

fn syncFullscreenChrome(self: *WindowState) void {
const tv = self.toolbar_view orelse return;
const is_fullscreen = c.gtk_window_is_fullscreen(@ptrCast(self.gtk_window));
c.adw_toolbar_view_set_reveal_top_bars(@ptrCast(tv), if (is_fullscreen != 0) 0 else 1);
}

/// Build the SSD chrome: the banner_box becomes the window's direct
/// content, the sidebar optionally grows a footer with action buttons, and we
/// ask the windowing system to draw decorations.
Expand Down Expand Up @@ -1584,6 +1600,16 @@ pub fn create(wm: *WindowManager) !*WindowState {
0,
);

// Hide CSD title bar when entering fullscreen
_ = c.g_signal_connect_data(
@as(c.gpointer, @ptrCast(window)),
"notify::fullscreened",
@as(c.GCallback, @ptrCast(&onFullscreenChanged)),
@ptrCast(state),
null,
0,
);

// Load theme-aware CSS
loadThemeCss();

Expand Down Expand Up @@ -1727,6 +1753,11 @@ fn onWindowFocusChanged(_: *c.GObject, _: ?*anyopaque, data: c.gpointer) callcon
}
}

fn onFullscreenChanged(_: *c.GObject, _: ?*anyopaque, data: c.gpointer) callconv(.c) void {
const state: *WindowState = @ptrCast(@alignCast(data));
state.syncFullscreenChrome();
}

fn onWorkspaceSelect(index: usize) void {
if (window_manager) |wm| if (wm.active_window) |state| state.selectWorkspace(index);
}
Expand Down
114 changes: 114 additions & 0 deletions tests/test_fullscreen.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
"""Fullscreen through physical keys, GTK and Openbox on isolated X servers."""
import json
from pathlib import Path
import shlex
import shutil
import subprocess
import tempfile

import test_keyboard_remaps as keyboard


class FullscreenTests(keyboard.PhysicalKeyboardTestCase):
config_extra = '\n[window]\ndecoration-mode = "csd"\n'

@classmethod
def setUpClass(cls):
cls.fixture_dir = tempfile.TemporaryDirectory(prefix="seance-fullscreen-")
cls.addClassCleanup(cls.fixture_dir.cleanup)
cls.module = str(Path(cls.fixture_dir.name) / "window-probe.so")
flags = subprocess.check_output(["pkg-config", "--cflags", "--libs", "libadwaita-1"], text=True)
subprocess.run(["cc", "-shared", "-fPIC", "-Wall", "-Wextra", "-Werror",
str(Path(__file__).with_name("window_probe.c")), "-o", cls.module,
*shlex.split(flags)], check=True)

def setUp(self):
if not shutil.which("openbox"):
self.skipTest("fullscreen tests require openbox")
self.state_file = Path(self.fixture_dir.name) / "windows.json"
self.state_file.write_text("{}")
self.env_extra = {"LD_PRELOAD": self.module,
"SEANCE_TEST_WINDOW_STATE": str(self.state_file),
"XDG_CURRENT_DESKTOP": ""}
super().setUp()
# No desktop configuration or WM keybindings may intercept our keys.
config = self.app.root / "openbox.xml"
config.write_text('<openbox_config xmlns="http://openbox.org/3.4/rc"/>')
wm_env = self.app.env.copy()
wm_env.pop("LD_PRELOAD", None)
wm = subprocess.Popen(["openbox", "--sm-disable", "--config-file", str(config)],
env=wm_env, stdout=subprocess.PIPE, stderr=self.app.log)
self.addCleanup(self.stop_server, wm)
self.window = self.run_x("xdotool", "search", "--onlyvisible", "--pid",
str(self.app.process.pid)).strip().splitlines()[0]
self.app.wait(lambda: subprocess.run(
["xdotool", "windowactivate", self.window], env=self.app.env,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=10).returncode == 0,
"window manager startup")
self.run_x("xdotool", "windowactivate", "--sync", self.window)
self.app.wait(lambda: self.state().get("header_height", 0) > 0, "initial CSD header")

def state(self, window=None):
return json.loads(self.state_file.read_text()).get(window or self.window, {})

def expect_state(self, fullscreen, header):
self.app.wait(lambda: self.state().get("fullscreen") == int(fullscreen) and
(self.state().get("header_height", -2) == header if header <= 0 else
self.state().get("header_height", 0) > 0),
f"fullscreen={fullscreen}, header={header}; last state={self.state()}")

def palette_toggle(self):
self.run_x("xdotool", "key", "ctrl+shift+p")
self.app.wait(lambda: self.state().get("search_focused"), "command palette focus")
self.run_x("xdotool", "type", "Toggle Fullscreen")
# GtkSearchEntry debounces search-changed; Enter must use the new results.
self.app.wait(lambda: self.state().get("fullscreen_selected"), "fullscreen search result")
self.run_x("xdotool", "key", "Return")

def test_f11_and_palette_restore_header_and_terminal_input(self):
self.capture()
self.run_x("xdotool", "key", "F11")
self.expect_state(True, 0)
self.run_x("xdotool", "key", "a")
self.palette_toggle()
self.expect_state(False, 1)
self.palette_toggle()
self.expect_state(True, 0)
self.run_x("xdotool", "key", "F11")
self.expect_state(False, 1)
self.run_x("xdotool", "key", "b")
self.app.wait(lambda: self.received().endswith(b"b"), "typing after fullscreen")
self.assertEqual(self.received(), b"ab", "fullscreen shortcuts must not reach the terminal")

def test_decoration_reload_keeps_fullscreen_header_hidden(self):
self.run_x("xdotool", "key", "F11")
self.expect_state(True, 0)
config = self.app.root / "config" / "seance" / "config.toml"
original = config.read_text()
config.write_text(original.replace('decoration-mode = "csd"', 'decoration-mode = "ssd"'))
self.run_x("xdotool", "key", "ctrl+shift+comma")
self.expect_state(True, -1)
self.run_x("xdotool", "key", "F11")
self.expect_state(False, -1)
self.run_x("xdotool", "key", "F11")
self.expect_state(True, -1)
config.write_text(original)
self.run_x("xdotool", "key", "ctrl+shift+comma")
self.expect_state(True, 0)
self.run_x("xdotool", "key", "F11")
self.expect_state(False, 1)

def test_fullscreen_binding_can_be_reassigned(self):
config = self.app.root / "config" / "seance" / "config.toml"
with config.open("a") as output:
output.write('\n[keybinds]\ntoggle-fullscreen = "F12"\n')
self.run_x("xdotool", "key", "ctrl+shift+comma")
self.capture()
self.run_x("xdotool", "key", "F11", "a")
self.app.wait(lambda: self.received().endswith(b"a"), "unbound F11 delivered to terminal")
self.assertEqual(self.received(), b"\x1b[23~a")
self.expect_state(False, 1)
self.run_x("xdotool", "key", "F12")
self.expect_state(True, 0)
self.run_x("xdotool", "key", "F12")
self.expect_state(False, 1)
20 changes: 17 additions & 3 deletions tests/test_ghostty_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@


@unittest.skipUnless(os.environ.get("SEANCE_TEST_BINARY"), "set SEANCE_TEST_BINARY for GUI validation")
class GhosttyIntegrationTests(unittest.TestCase):
class GhosttyTestCase(unittest.TestCase):
config_extra = ""
env_extra = {}
shell_command = "/bin/bash --noprofile --norc"

def setUp(self):
self.tmp = tempfile.TemporaryDirectory(prefix="seance-ghostty-")
Expand All @@ -27,6 +29,7 @@ def setUp(self):
self.env[key] = str(path)
self.env.update(GDK_BACKEND="x11", DBUS_SESSION_BUS_ADDRESS="disabled:",
LIBGL_ALWAYS_SOFTWARE="1", NO_AT_BRIDGE="1", SHELL="/bin/bash")
self.env.update(self.env_extra)
self.env.pop("SEANCE_DISABLE_SESSION_RESTORE", None)
config = self.root / "config" / "seance"
config.mkdir()
Expand All @@ -35,13 +38,17 @@ def setUp(self):
'\n[behavior]\nconfirm-close-window = false\n' + self.config_extra)
ghostty = self.root / "config" / "ghostty"
ghostty.mkdir()
(ghostty / "config").write_text("command = /bin/bash --noprofile --norc\n")
(ghostty / "config").write_text("command = " + self.shell_command + "\n")
self.log = (self.root / "stderr.log").open("w+")
self.addCleanup(self.log.close)
self.process = None
self.addCleanup(self.stop)
self.prepare()
self.start()

def prepare(self):
"""Set up shell files or fixtures before the first surface starts."""

def start(self):
self.process = subprocess.Popen([self.binary], env=self.env, cwd=self.root,
stdout=subprocess.DEVNULL, stderr=self.log)
Expand Down Expand Up @@ -79,14 +86,19 @@ def wait(self, check, description):
try:
if check():
return
last = None
except (OSError, ValueError, AssertionError) as exc:
last = exc
if self.process.poll() is not None:
break
time.sleep(0.1)
self.log.flush()
self.log.seek(0)
self.fail(f"{description}: {last}\n{self.log.read()[-8000:]}")
try:
screen = self.call("surface.read_screen")["text"]
except (OSError, ValueError, AssertionError):
screen = "<unavailable>"
self.fail(f"{description}: {last}\n{self.log.read()[-8000:]}\nTerminal:\n{screen}")

def print_marker(self, marker, surface=None):
params = {"text": "printf '\\033[31mSEANCE_%s\\033[0m\\n' " + marker + "\n"}
Expand All @@ -97,6 +109,8 @@ def print_marker(self, marker, surface=None):
"surface.read_screen", {"surface_id": surface} if surface else None)["text"],
"terminal output")


class GhosttyIntegrationTests(GhosttyTestCase):
def test_indexed_scrollback_survives_restart_and_cli_reads_it(self):
self.wait(lambda: self.call("surface.read_screen").get("text"), "shell startup")
self.print_marker("SAVED_COLOR_42")
Expand Down
Loading
Loading