title: add agent CLI sparkle indicator flag
parent
a4cb73db84
commit
c85e7bf14b
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {};
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
Loading…
Reference in New Issue