feat(osc): add iTerm2 OSC 6 tab color support

Parse the iTerm2 OSC 6 tab color sequence and wire it to the existing
macOS tab color. Each color channel is set with its own sequence
(iTerm2 sets red/green/blue independently) and a wildcard channel
resets the color to the default:

    OSC 6 ; 1 ; bg ; red   ; brightness ; <0-255> ST
    OSC 6 ; 1 ; bg ; * ; default ST

The parser emits a new `tab_color` command; the stream handler
accumulates the channels into a full RGB value and forwards it to the
apprt via a new `tab_color` action ({ reset, r, g, b }). On macOS the
RGB is mapped to the nearest entry in the existing tab color palette.
GTK leaves the action unimplemented for now.

Requested in discussion #12778 and issue #2509.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
pull/12856/head
fberrez 2026-05-29 11:05:21 +02:00
parent cb36966a75
commit 14ed299ce6
No known key found for this signature in database
13 changed files with 311 additions and 1 deletions

View File

@ -797,6 +797,14 @@ typedef struct {
uint8_t b;
} ghostty_action_color_change_s;
// apprt.action.TabColor
typedef struct {
bool reset;
uint8_t r;
uint8_t g;
uint8_t b;
} ghostty_action_tab_color_s;
// apprt.action.ConfigChange
typedef struct {
ghostty_config_t config;
@ -930,6 +938,7 @@ typedef enum {
GHOSTTY_ACTION_KEY_SEQUENCE,
GHOSTTY_ACTION_KEY_TABLE,
GHOSTTY_ACTION_COLOR_CHANGE,
GHOSTTY_ACTION_TAB_COLOR,
GHOSTTY_ACTION_RELOAD_CONFIG,
GHOSTTY_ACTION_CONFIG_CHANGE,
GHOSTTY_ACTION_CLOSE_WINDOW,
@ -978,6 +987,7 @@ typedef union {
ghostty_action_key_sequence_s key_sequence;
ghostty_action_key_table_s key_table;
ghostty_action_color_change_s color_change;
ghostty_action_tab_color_s tab_color;
ghostty_action_reload_config_s reload_config;
ghostty_action_config_change_s config_change;
ghostty_action_open_url_s open_url;

View File

@ -38,6 +38,31 @@ enum TerminalTabColor: Int, CaseIterable, Codable {
}
}
/// Returns the preset color closest to the given RGB components. This is
/// used to map an arbitrary color (such as one received from the iTerm2
/// OSC 6 tab color sequence) onto Ghostty's fixed tab color palette.
static func nearest(red: UInt8, green: UInt8, blue: UInt8) -> TerminalTabColor {
let targetR = Double(red)
let targetG = Double(green)
let targetB = Double(blue)
var best: TerminalTabColor = .none
var bestDistance = Double.greatestFiniteMagnitude
for candidate in TerminalTabColor.allCases {
guard let nsColor = candidate.displayColor?.usingColorSpace(.sRGB) else { continue }
let dr = nsColor.redComponent * 255.0 - targetR
let dg = nsColor.greenComponent * 255.0 - targetG
let db = nsColor.blueComponent * 255.0 - targetB
let distance = dr * dr + dg * dg + db * db
if distance < bestDistance {
bestDistance = distance
best = candidate
}
}
return best
}
var displayColor: NSColor? {
switch self {
case .none:

View File

@ -617,6 +617,9 @@ extension Ghostty {
case GHOSTTY_ACTION_COLOR_CHANGE:
colorChange(app, target: target, change: action.action.color_change)
case GHOSTTY_ACTION_TAB_COLOR:
return tabColor(app, target: target, v: action.action.tab_color)
case GHOSTTY_ACTION_RING_BELL:
ringBell(app, target: target)
@ -1637,6 +1640,31 @@ extension Ghostty {
}
}
private static func tabColor(
_ app: ghostty_app_t,
target: ghostty_target_s,
v: ghostty_action_tab_color_s
) -> Bool {
switch target.tag {
case GHOSTTY_TARGET_APP:
Ghostty.logger.warning("tab color does nothing with an app target")
return false
case GHOSTTY_TARGET_SURFACE:
guard let surface = target.target.surface else { return false }
guard let surfaceView = self.surfaceView(from: surface) else { return false }
guard let window = surfaceView.window as? TerminalWindow else { return false }
window.tabColor = v.reset
? .none
: .nearest(red: v.r, green: v.g, blue: v.b)
return true
default:
assertionFailure()
return false
}
}
private static func showChildExited(
_ app: ghostty_app_t,
target: ghostty_target_s,

View File

@ -1031,6 +1031,19 @@ pub fn handleMessage(self: *Surface, msg: Message) !void {
);
},
.tab_color_change => |change| {
_ = try self.rt_app.performAction(
.{ .surface = self },
.tab_color,
.{
.reset = change.reset,
.r = change.color.r,
.g = change.color.g,
.b = change.color.b,
},
);
},
.set_mouse_shape => |shape| {
log.debug("changing mouse shape: {}", .{shape});
_ = try self.rt_app.performAction(

View File

@ -265,6 +265,10 @@ pub const Action = union(Key) {
/// such as OSC 10/11.
color_change: ColorChange,
/// The tab's background color was changed programmatically through
/// the iTerm2 OSC 6 sequence.
tab_color: TabColor,
/// A request to reload the configuration. The reload request can be
/// from a user or for some internal reason. The reload request may
/// request it is a soft reload or a full reload. See the struct for
@ -392,6 +396,7 @@ pub const Action = union(Key) {
key_sequence,
key_table,
color_change,
tab_color,
reload_config,
config_change,
close_window,
@ -855,6 +860,15 @@ pub const ColorKind = enum(c_int) {
// }
};
pub const TabColor = extern struct {
/// When true the tab color should be reset to the default and the
/// r/g/b fields should be ignored.
reset: bool,
r: u8,
g: u8,
b: u8,
};
pub const ReloadConfig = extern struct {
/// A soft reload means that the configuration doesn't need to be
/// read off disk, but libghostty needs the full config again so call

View File

@ -780,6 +780,7 @@ pub const Application = extern struct {
.render_inspector,
.renderer_health,
.color_change,
.tab_color,
.reset_window_size,
.check_for_updates,
.undo,

View File

@ -75,6 +75,14 @@ pub const Message = union(enum) {
/// A terminal color was changed using OSC sequences.
color_change: terminal.osc.color.ColoredTarget,
/// The tab's background color was changed using the iTerm2 OSC 6
/// sequence. When `reset` is true the color should revert to the
/// default and `color` should be ignored.
tab_color_change: struct {
reset: bool,
color: terminal.color.RGB = .{ .r = 0, .g = 0, .b = 0 },
},
/// Notifies the surface that a tick of the timer that is timing
/// out selection scrolling has occurred. "selection scrolling"
/// is when the user has clicked and dragged the mouse outside

View File

@ -160,10 +160,40 @@ pub const Command = union(Key) {
/// https://uapi-group.org/specifications/specs/osc_context/
context_signal: parsers.context_signal.Command,
/// OSC 6. Set or reset the tab's background color. This follows the
/// iTerm2 convention where each color channel is set with a separate
/// sequence and a wildcard channel resets the color to the default.
/// https://iterm2.com/documentation-escape-codes.html
tab_color: TabColor,
pub const SemanticPrompt = parsers.semantic_prompt.Command;
pub const KittyClipboardProtocol = parsers.kitty_clipboard_protocol.OSC;
/// The payload for the `tab_color` command (OSC 6). iTerm2 sends one
/// channel per sequence, so consumers are expected to accumulate the
/// channels into a full color.
pub const TabColor = union(enum) {
/// Reset the tab color to the default.
reset,
/// Set a single channel of the tab color.
set: struct {
channel: Channel,
value: u8,
},
pub const Channel = enum { red, green, blue };
// This command is fully handled on the Zig side (it is translated
// into an apprt action) so it is never exposed across the vt C ABI.
pub const C = void;
pub fn cval(_: TabColor) TabColor.C {
return {};
}
};
pub const Key = LibEnum(
lib.target,
// NOTE: Order matters, see LibEnum documentation.
@ -193,6 +223,7 @@ pub const Command = union(Key) {
"kitty_text_sizing",
"kitty_clipboard_protocol",
"context_signal",
"tab_color",
},
);
@ -422,6 +453,7 @@ pub const Parser = struct {
.kitty_text_sizing,
.kitty_clipboard_protocol,
.context_signal,
.tab_color,
=> {},
}
@ -673,6 +705,7 @@ pub const Parser = struct {
},
.@"6" => switch (c) {
';' => self.captureTrailing(.fixed),
'6' => self.state = .@"66",
else => self.state = .invalid,
},
@ -801,7 +834,7 @@ pub const Parser = struct {
.@"3008" => parsers.context_signal.parse(self, terminator_ch),
.@"6" => null,
.@"6" => parsers.iterm2_tab_color.parse(self, terminator_ch),
.@"66" => parsers.kitty_text_sizing.parse(self, terminator_ch),

View File

@ -7,6 +7,7 @@ pub const clipboard_operation = @import("parsers/clipboard_operation.zig");
pub const color = @import("parsers/color.zig");
pub const hyperlink = @import("parsers/hyperlink.zig");
pub const iterm2 = @import("parsers/iterm2.zig");
pub const iterm2_tab_color = @import("parsers/iterm2_tab_color.zig");
pub const kitty_clipboard_protocol = @import("parsers/kitty_clipboard_protocol.zig");
pub const kitty_color = @import("parsers/kitty_color.zig");
pub const kitty_text_sizing = @import("parsers/kitty_text_sizing.zig");

View File

@ -0,0 +1,147 @@
const std = @import("std");
const Parser = @import("../../osc.zig").Parser;
const Command = @import("../../osc.zig").Command;
const TabColor = Command.TabColor;
const log = std.log.scoped(.osc_tab_color);
/// Parse OSC 6, the iTerm2 tab color sequence.
///
/// iTerm2 sets the tab's background color one channel at a time:
///
/// OSC 6 ; 1 ; bg ; red ; brightness ; <0-255> ST
/// OSC 6 ; 1 ; bg ; green ; brightness ; <0-255> ST
/// OSC 6 ; 1 ; bg ; blue ; brightness ; <0-255> ST
///
/// And resets it to the default with a wildcard channel:
///
/// OSC 6 ; 1 ; bg ; * ; default ST
///
/// We only support the background ("bg") target since that is what colors
/// the tab. The leading mode field ("1") and the colorspace field
/// ("brightness") are accepted but not otherwise interpreted.
/// https://iterm2.com/documentation-escape-codes.html
pub fn parse(parser: *Parser, _: ?u8) ?*Command {
const data = data: {
const cap = if (parser.capture) |*c| c else {
parser.state = .invalid;
return null;
};
break :data cap.trailing();
};
var it = std.mem.splitScalar(u8, data, ';');
// Mode field. iTerm2 uses "1" here; we accept any value.
_ = it.next() orelse return invalid(parser);
// Target. We only support the tab background color.
const target = it.next() orelse return invalid(parser);
if (!std.ascii.eqlIgnoreCase(target, "bg")) return invalid(parser);
// Channel: red, green, blue, or "*" for reset.
const channel = it.next() orelse return invalid(parser);
if (std.mem.eql(u8, channel, "*")) {
// Reset form. The next field must be "default".
const reset = it.next() orelse return invalid(parser);
if (!std.ascii.eqlIgnoreCase(reset, "default")) return invalid(parser);
parser.command = .{ .tab_color = .reset };
return &parser.command;
}
const ch: TabColor.Channel =
if (std.ascii.eqlIgnoreCase(channel, "red")) .red else if (std.ascii.eqlIgnoreCase(channel, "green")) .green else if (std.ascii.eqlIgnoreCase(channel, "blue")) .blue else return invalid(parser);
// Colorspace field. iTerm2 uses "brightness"; we accept any value.
_ = it.next() orelse return invalid(parser);
// Channel value, 0-255.
const value_str = it.next() orelse return invalid(parser);
const value = std.fmt.parseInt(u8, value_str, 10) catch return invalid(parser);
parser.command = .{ .tab_color = .{ .set = .{ .channel = ch, .value = value } } };
return &parser.command;
}
fn invalid(parser: *Parser) ?*Command {
parser.command = .invalid;
return null;
}
test "OSC 6: set red channel" {
const testing = std.testing;
var p: Parser = .init(null);
for ("6;1;bg;red;brightness;128") |ch| p.next(ch);
const cmd = p.end(null).?.*;
try testing.expect(cmd == .tab_color);
try testing.expect(cmd.tab_color == .set);
try testing.expectEqual(TabColor.Channel.red, cmd.tab_color.set.channel);
try testing.expectEqual(@as(u8, 128), cmd.tab_color.set.value);
}
test "OSC 6: set green channel max" {
const testing = std.testing;
var p: Parser = .init(null);
for ("6;1;bg;green;brightness;255") |ch| p.next(ch);
const cmd = p.end(null).?.*;
try testing.expect(cmd == .tab_color);
try testing.expectEqual(TabColor.Channel.green, cmd.tab_color.set.channel);
try testing.expectEqual(@as(u8, 255), cmd.tab_color.set.value);
}
test "OSC 6: reset" {
const testing = std.testing;
var p: Parser = .init(null);
for ("6;1;bg;*;default") |ch| p.next(ch);
const cmd = p.end(null).?.*;
try testing.expect(cmd == .tab_color);
try testing.expect(cmd.tab_color == .reset);
}
test "OSC 6: case insensitive" {
const testing = std.testing;
var p: Parser = .init(null);
for ("6;1;BG;Blue;Brightness;10") |ch| p.next(ch);
const cmd = p.end(null).?.*;
try testing.expect(cmd == .tab_color);
try testing.expectEqual(TabColor.Channel.blue, cmd.tab_color.set.channel);
}
test "OSC 6: foreground target unsupported" {
const testing = std.testing;
var p: Parser = .init(null);
for ("6;1;fg;red;brightness;10") |ch| p.next(ch);
try testing.expect(p.end(null) == null);
}
test "OSC 6: unknown channel" {
const testing = std.testing;
var p: Parser = .init(null);
for ("6;1;bg;alpha;brightness;10") |ch| p.next(ch);
try testing.expect(p.end(null) == null);
}
test "OSC 6: value out of range" {
const testing = std.testing;
var p: Parser = .init(null);
for ("6;1;bg;red;brightness;300") |ch| p.next(ch);
try testing.expect(p.end(null) == null);
}
test "OSC 6: missing fields" {
const testing = std.testing;
var p: Parser = .init(null);
for ("6;1;bg") |ch| p.next(ch);
try testing.expect(p.end(null) == null);
}

View File

@ -125,6 +125,7 @@ pub const Action = union(Key) {
kitty_color_report: kitty.color.OSC,
color_operation: ColorOperation,
semantic_prompt: SemanticPrompt,
tab_color: osc.Command.TabColor,
pub const Key = lib.Enum(
lib.target,
@ -222,6 +223,7 @@ pub const Action = union(Key) {
"kitty_color_report",
"color_operation",
"semantic_prompt",
"tab_color",
},
);
@ -2023,6 +2025,10 @@ pub fn Stream(comptime H: type) type {
self.handler.vt(.kitty_color_report, v);
},
.tab_color => |v| {
self.handler.vt(.tab_color, v);
},
.show_desktop_notification => |v| {
self.handler.vt(.show_desktop_notification, .{
.title = v.title,

View File

@ -267,6 +267,7 @@ pub const Handler = struct {
.clipboard_contents,
.title_push,
.title_pop,
.tab_color,
=> {},
}
}

View File

@ -70,6 +70,11 @@ pub const StreamHandler = struct {
/// such as XTGETTCAP.
dcs: terminal.dcs.Handler = .{},
/// Accumulator for the iTerm2 tab color sequence (OSC 6). iTerm2 sets
/// the tab color one channel at a time, so we accumulate the channels
/// here and send the full color to the apprt on each update.
tab_color: terminal.color.RGB = .{ .r = 0, .g = 0, .b = 0 },
/// The tmux control mode viewer state.
tmux_viewer: if (tmux_enabled) ?*terminal.tmux.Viewer else void = if (tmux_enabled) null else {},
@ -321,6 +326,7 @@ pub const StreamHandler = struct {
},
.kitty_color_report => try self.kittyColorReport(value),
.color_operation => try self.colorOperation(value.op, &value.requests, value.terminator),
.tab_color => self.tabColor(value),
.end_hyperlink => try self.endHyperlink(),
.active_status_display => self.terminal.status_display = value,
.decaln => try self.decaln(),
@ -1206,6 +1212,23 @@ pub const StreamHandler = struct {
}
}
fn tabColor(self: *StreamHandler, v: terminal.osc.Command.TabColor) void {
switch (v) {
.reset => self.surfaceMessageWriter(.{ .tab_color_change = .{ .reset = true } }),
.set => |set| {
switch (set.channel) {
.red => self.tab_color.r = set.value,
.green => self.tab_color.g = set.value,
.blue => self.tab_color.b = set.value,
}
self.surfaceMessageWriter(.{ .tab_color_change = .{
.reset = false,
.color = self.tab_color,
} });
},
}
}
fn colorOperation(
self: *StreamHandler,
op: terminal.osc.color.Operation,