title: add agent CLI sparkle indicator flag

pull/9947/head
George Papadakis 2025-12-17 22:18:01 +02:00
parent a4cb73db84
commit c85e7bf14b
11 changed files with 283 additions and 9 deletions

View File

@ -1008,6 +1008,7 @@ ghostty_surface_config_s ghostty_surface_inherited_config(ghostty_surface_t);
void ghostty_surface_update_config(ghostty_surface_t, ghostty_config_t);
bool ghostty_surface_needs_confirm_quit(ghostty_surface_t);
bool ghostty_surface_process_exited(ghostty_surface_t);
bool ghostty_surface_agent_running(ghostty_surface_t);
void ghostty_surface_refresh(ghostty_surface_t);
void ghostty_surface_draw(ghostty_surface_t);
void ghostty_surface_set_content_scale(ghostty_surface_t, double, double);

View File

@ -766,9 +766,8 @@ class BaseTerminalController: NSWindowController,
if let titleSurface = focusedSurface ?? lastFocusedSurface,
surfaceTree.contains(titleSurface) {
// If we have a surface, we want to listen for title changes.
titleSurface.$title
.combineLatest(titleSurface.$bell)
.map { [weak self] in self?.computeTitle(title: $0, bell: $1) ?? "" }
Publishers.CombineLatest3(titleSurface.$title, titleSurface.$bell, titleSurface.$agentRunning)
.map { [weak self] in self?.computeTitle(title: $0, bell: $1, agentRunning: $2) ?? "" }
.sink { [weak self] in self?.titleDidChange(to: $0) }
.store(in: &focusedSurfaceCancellables)
} else {
@ -777,13 +776,18 @@ class BaseTerminalController: NSWindowController,
}
}
private func computeTitle(title: String, bell: Bool) -> String {
var result = title
if (bell && ghostty.config.bellFeatures.contains(.title)) {
result = "🔔 \(result)"
private func computeTitle(title: String, bell: Bool, agentRunning: Bool) -> String {
var prefixes: [String] = []
if agentRunning && ghostty.config.titleAgentIndicator {
prefixes.append("")
}
if bell && ghostty.config.bellFeatures.contains(.title) {
prefixes.append("🔔")
}
return result
if prefixes.isEmpty { return title }
return prefixes.joined(separator: " ") + " " + title
}
private func titleDidChange(to: String) {

View File

@ -157,6 +157,14 @@ extension Ghostty {
return String(cString: ptr)
}
var titleAgentIndicator: Bool {
guard let config = self.config else { return false }
var v = false
let key = "title-agent-indicator"
_ = ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8)))
return v
}
var windowSaveState: String {
guard let config = self.config else { return "" }
var v: UnsafePointer<Int8>? = nil

View File

@ -123,6 +123,13 @@ extension Ghostty {
/// True when the bell is active. This is set inactive on focus or event.
@Published private(set) var bell: Bool = false
/// True when an "agent CLI" (gemini/codex/claude) is detected as the
/// foreground process for this surface.
///
/// This is best-effort and is only used when enabled by config
/// (`title-agent-indicator`).
@Published private(set) var agentRunning: Bool = false
/// True when the surface is in readonly mode.
@Published private(set) var readonly: Bool = false
@ -220,6 +227,9 @@ extension Ghostty {
// Timer to remove progress report after 15 seconds
private var progressReportTimer: Timer?
// Timer to periodically update `agentRunning`.
private var agentRunningTimer: Timer?
// This is the title from the terminal. This is nil if we're currently using
// the terminal title as the main title property. If the title is set manually
// by the user, this is set to the prior value (which may be empty, but non-nil).
@ -377,6 +387,11 @@ extension Ghostty {
}
self.surfaceModel = Ghostty.Surface(cSurface: surface)
// Poll the foreground process name periodically so we can update UI
// indicators (tab title prefix) even if the terminal title itself
// doesn't change.
startAgentRunningTimer()
// Setup our tracking area so we get mouse moved events
updateTrackingAreas()
@ -413,6 +428,39 @@ extension Ghostty {
// Cancel progress report timer
progressReportTimer?.invalidate()
// Cancel agent running timer
agentRunningTimer?.invalidate()
}
private func startAgentRunningTimer() {
guard agentRunningTimer == nil else { return }
// Poll relatively infrequently; this is lightweight but shouldn't
// run on every frame.
agentRunningTimer = Timer.scheduledTimer(withTimeInterval: 0.75, repeats: true) { [weak self] _ in
self?.refreshAgentRunning()
}
// Prime the state immediately.
refreshAgentRunning()
}
private func refreshAgentRunning() {
guard let surface else {
if agentRunning { agentRunning = false }
return
}
// Skip work unless explicitly enabled.
let enabled = (NSApplication.shared.delegate as? AppDelegate)?.ghostty.config.titleAgentIndicator ?? false
if !enabled {
if agentRunning { agentRunning = false }
return
}
let next = ghostty_surface_agent_running(surface)
if next != agentRunning { agentRunning = next }
}
func focusDidChange(_ focused: Bool) {

View File

@ -34,6 +34,7 @@ const Duration = configpkg.Config.Duration;
const input = @import("input.zig");
const App = @import("App.zig");
const internal_os = @import("os/main.zig");
const foreground_process = @import("os/foreground_process.zig");
const inspectorpkg = @import("inspector/main.zig");
const SurfaceMouse = @import("surface_mouse.zig");
@ -923,6 +924,33 @@ pub fn needsConfirmQuit(self: *Surface) bool {
};
}
/// Returns true if we can detect that a supported "agent CLI" is currently the
/// foreground process for this surface.
///
/// This is intentionally best-effort (and behind a UI config flag) because
/// foreground process detection is OS-dependent and may fail in some
/// sandboxing/permission scenarios.
pub fn agentCliRunning(self: *Surface) bool {
if (comptime builtin.os.tag == .windows) return false;
if (comptime builtin.os.tag == .ios) return false;
// Only exec is supported today; other backends can return false.
const pty_master_fd: std.posix.fd_t = switch (self.io.backend) {
.exec => |exec| blk: {
const pty = exec.subprocess.pty orelse return false;
break :blk pty.master;
},
};
var name_buf: [256]u8 = undefined;
const name = foreground_process.foregroundProcessNameFromPtyMaster(
pty_master_fd,
name_buf[0..],
) orelse return false;
return foreground_process.isAgentCliProcessName(name);
}
/// Called from the app thread to handle mailbox messages to our specific
/// surface.
pub fn handleMessage(self: *Surface, msg: Message) !void {

View File

@ -1542,6 +1542,12 @@ pub const CAPI = struct {
return surface.core_surface.child_exited;
}
/// Returns true if a supported "agent CLI" is currently the foreground
/// process for this surface (best-effort).
export fn ghostty_surface_agent_running(surface: *Surface) bool {
return surface.core_surface.agentCliRunning();
}
/// Returns true if the surface has a selection.
export fn ghostty_surface_has_selection(surface: *Surface) bool {
return surface.core_surface.hasSelection();

View File

@ -75,6 +75,21 @@ pub const Surface = extern struct {
);
};
/// True when we detect that a supported "agent CLI" (gemini/codex/claude)
/// is currently running in the foreground for this surface.
pub const @"agent-running" = struct {
pub const name = "agent-running";
const impl = gobject.ext.defineProperty(
name,
Self,
bool,
.{
.default = false,
.accessor = C.privateShallowFieldAccessor("agent_running"),
},
);
};
pub const config = struct {
pub const name = "config";
const impl = gobject.ext.defineProperty(
@ -587,11 +602,17 @@ pub const Surface = extern struct {
// Progress bar
progress_bar_timer: ?c_uint = null,
// Poll timer for updating `agent-running`.
agent_poll_timer: ?c_uint = null,
// True while the bell is ringing. This will be set to false (after
// true) under various scenarios, but can also manually be set to
// false by a parent widget.
bell_ringing: bool = false,
// True when an agent CLI is detected as the foreground process.
agent_running: bool = false,
/// True if this surface is in an error state. This is currently
/// a simple boolean with no additional information on WHAT the
/// error state is, because we don't yet need it or use it. For now,
@ -1716,6 +1737,13 @@ pub const Surface = extern struct {
priv.progress_bar_timer = null;
}
if (priv.agent_poll_timer) |timer| {
if (glib.Source.remove(timer) == 0) {
log.warn("unable to remove agent poll timer", .{});
}
priv.agent_poll_timer = null;
}
if (priv.idle_rechild) |v| {
if (glib.Source.remove(v) == 0) {
log.warn("unable to remove idle source", .{});
@ -2023,6 +2051,58 @@ pub const Surface = extern struct {
&valign,
);
}
// title-agent-indicator (best-effort; disabled by default)
if (config.@"title-agent-indicator") {
self.ensureAgentPollTimer();
} else {
self.stopAgentPollTimer();
}
}
fn ensureAgentPollTimer(self: *Self) void {
const priv = self.private();
if (priv.agent_poll_timer != null) return;
// Poll relatively infrequently; we only use this to keep UI state
// updated even if the terminal title doesn't change.
priv.agent_poll_timer = glib.timeoutAdd(750, onAgentPollTimer, self);
// Prime the state so enabling the flag updates quickly.
self.pollAgentRunning();
}
fn stopAgentPollTimer(self: *Self) void {
const priv = self.private();
if (priv.agent_poll_timer) |timer| {
_ = glib.Source.remove(timer);
priv.agent_poll_timer = null;
}
if (priv.agent_running) {
priv.agent_running = false;
self.as(gobject.Object).notifyByPspec(properties.@"agent-running".impl.param_spec);
}
}
fn onAgentPollTimer(ud: ?*anyopaque) callconv(.c) c_int {
const self: *Self = @ptrCast(@alignCast(ud orelse return 0));
self.pollAgentRunning();
return 1;
}
fn pollAgentRunning(self: *Self) void {
const priv = self.private();
const config = if (priv.config) |c| c.get() else return;
if (!config.@"title-agent-indicator") return;
const next = if (priv.core_surface) |core_surface| core_surface.agentCliRunning() else false;
if (next != priv.agent_running) {
priv.agent_running = next;
self.as(gobject.Object).notifyByPspec(properties.@"agent-running".impl.param_spec);
}
}
fn propError(
@ -3301,6 +3381,7 @@ pub const Surface = extern struct {
// Properties
gobject.ext.registerProperties(class, &.{
properties.@"bell-ringing".impl,
properties.@"agent-running".impl,
properties.config.impl,
properties.@"child-exited".impl,
properties.@"default-size".impl,

View File

@ -375,10 +375,12 @@ pub const Tab = extern struct {
override_: ?[*:0]const u8,
zoomed_: c_int,
bell_ringing_: c_int,
agent_running_: c_int,
_: *gobject.ParamSpec,
) callconv(.c) ?[*:0]const u8 {
const zoomed = zoomed_ != 0;
const bell_ringing = bell_ringing_ != 0;
const agent_running = agent_running_ != 0;
// Our plain title is the overridden title if it exists, otherwise
// the terminal title if it exists, otherwise a default string.
@ -409,6 +411,11 @@ pub const Tab = extern struct {
var buf: std.Io.Writer.Allocating = .init(Application.default().allocator());
defer buf.deinit();
// If an agent CLI is running, prefix with a sparkle emoji.
if (agent_running and config.@"title-agent-indicator") {
buf.writer.writeAll("") catch {};
}
// If our bell is ringing, then we prefix the bell icon to the title.
if (bell_ringing and config.@"bell-features".title) {
buf.writer.writeAll("🔔 ") catch {};

View File

@ -8,7 +8,7 @@ template $GhosttyTab: Box {
orientation: vertical;
hexpand: true;
vexpand: true;
title: bind $computed_title(template.config, split_tree.active-surface as <$GhosttySurface>.title, split_tree.active-surface as <$GhosttySurface>.title-override, split_tree.is-zoomed, split_tree.active-surface as <$GhosttySurface>.bell-ringing) as <string>;
title: bind $computed_title(template.config, split_tree.active-surface as <$GhosttySurface>.title, split_tree.active-surface as <$GhosttySurface>.title-override, split_tree.is-zoomed, split_tree.active-surface as <$GhosttySurface>.bell-ringing, split_tree.active-surface as <$GhosttySurface>.agent-running) as <string>;
tooltip: bind split_tree.active-surface as <$GhosttySurface>.pwd;
$GhosttySplitTree split_tree {

View File

@ -2772,6 +2772,15 @@ keybind: Keybinds = .{},
/// Available since: 1.2.0
@"bell-features": BellFeatures = .{},
/// When enabled, Ghostty will try to detect if the foreground process running
/// in a surface is a supported "agent CLI" (currently: `gemini`, `codex`,
/// `claude`). If so, Ghostty will prepend a sparkle emoji () to the tab title.
///
/// This is best-effort and OS-dependent.
///
/// Available since: 1.2.0
@"title-agent-indicator": bool = false,
/// If `audio` is an enabled bell feature, this is a path to an audio file. If
/// the path is not absolute, it is considered relative to the directory of the
/// configuration file that it is referenced from, or from the current working

View File

@ -0,0 +1,82 @@
const std = @import("std");
const builtin = @import("builtin");
const posix = std.posix;
const c = if (builtin.os.tag == .windows) struct {} else @cImport({
@cInclude("sys/ioctl.h");
});
// macOS: process name lookup helper (libproc).
extern fn proc_name(pid: c_int, buffer: [*]u8, buffersize: u32) callconv(.c) c_int;
/// Returns the basename of the process that is currently in the foreground
/// process group for the PTY associated with `pty_master_fd`.
///
/// This is best-effort: if we can't query it on the current platform (or due to
/// permissions), this returns `null` rather than erroring.
pub fn foregroundProcessNameFromPtyMaster(
pty_master_fd: posix.fd_t,
buf: []u8,
) ?[]const u8 {
if (comptime builtin.os.tag == .windows) return null;
if (comptime builtin.os.tag == .ios) return null;
if (buf.len == 0) return null;
var pgid: std.c.pid_t = 0;
if (c.ioctl(pty_master_fd, c.TIOCGPGRP, @intFromPtr(&pgid)) < 0) return null;
if (pgid <= 0) return null;
return processName(pgid, buf);
}
/// Returns the basename of the process with the given pid, if it can be
/// determined.
///
/// The returned slice is always a subslice of `buf`.
pub fn processName(pid: std.c.pid_t, buf: []u8) ?[]const u8 {
if (buf.len == 0) return null;
if (pid <= 0) return null;
return switch (comptime builtin.os.tag) {
.linux => linuxProcessName(pid, buf),
.macos => macosProcessName(pid, buf),
else => null,
};
}
pub fn isAgentCliProcessName(name: []const u8) bool {
// Keep this intentionally small and explicit; we'll expand to per-agent
// icons later.
const known = [_][]const u8{
"gemini",
"codex",
"claude",
};
for (known) |k| {
if (std.ascii.eqlIgnoreCase(name, k)) return true;
}
return false;
}
fn linuxProcessName(pid: std.c.pid_t, buf: []u8) ?[]const u8 {
var path_buf: [64]u8 = undefined;
const path = std.fmt.bufPrint(&path_buf, "/proc/{d}/comm", .{pid}) catch return null;
const file = std.fs.openFileAbsolute(path, .{}) catch return null;
defer file.close();
const n = file.readAll(buf) catch return null;
if (n == 0) return null;
return std.mem.trimRight(u8, buf[0..n], "\r\n");
}
fn macosProcessName(pid: std.c.pid_t, buf: []u8) ?[]const u8 {
const rc = proc_name(@intCast(pid), buf.ptr, @intCast(buf.len));
if (rc <= 0) return null;
return std.mem.sliceTo(buf[0..@intCast(rc)], 0);
}