From bc3b86760b145b85e82e6c1ea78cd32b553637a6 Mon Sep 17 00:00:00 2001 From: zetlen Date: Mon, 19 Jan 2026 18:17:48 -0600 Subject: [PATCH 1/7] feat(schema): add background image support to CTEC WindowConfig Add support for background images as a first-class CTEC feature: - BackgroundImageScale enum: contain, cover, stretch, tile, none, centered - BackgroundImagePosition enum: 9 positions (top-left through bottom-right) - WindowConfig fields: background_image, background_image_opacity, background_image_scale, background_image_position Implement parsing and export for Ghostty: - Parse background-image, background-image-opacity - Parse background-image-fit -> BackgroundImageScale - Parse background-image-position -> BackgroundImagePosition - Parse background-image-repeat=true -> BackgroundImageScale.TILE - Export with proper Ghostty format and fallback warnings Closes #57 Co-Authored-By: Claude Opus 4.5 --- console_cowboy/ctec/schema.py | 71 ++++++++++++++ console_cowboy/terminals/ghostty.py | 95 ++++++++++++++++++- tests/test_ghostty.py | 140 ++++++++++++++++++++++++++++ 3 files changed, 305 insertions(+), 1 deletion(-) diff --git a/console_cowboy/ctec/schema.py b/console_cowboy/ctec/schema.py index cbbc385..9218181 100644 --- a/console_cowboy/ctec/schema.py +++ b/console_cowboy/ctec/schema.py @@ -256,6 +256,52 @@ class TabBarStyle(Enum): SEPARATOR = "separator" # Kitty separator +class BackgroundImageScale(Enum): + """ + How to scale/fit the background image. + + Common across terminals: + - Ghostty: background-image-fit (contain, cover, stretch, none) + - Kitty: background_image_layout (scaled, cscaled, tiled, centered, clamped) + - WezTerm: via background config + - iTerm2: Mode (Stretch, Tile, Scale to Fill, Scale to Fit) + """ + + # Scale to fit entirely within window, preserving aspect ratio (letterbox/pillarbox) + CONTAIN = "contain" + # Scale to cover entire window, preserving aspect ratio (may crop) + COVER = "cover" + # Stretch to fill window exactly, ignoring aspect ratio + STRETCH = "stretch" + # Tile/repeat the image without scaling + TILE = "tile" + # No scaling, display at original size + NONE = "none" + # Centered without scaling (Kitty-specific but useful) + CENTERED = "centered" + + +class BackgroundImagePosition(Enum): + """ + Position of the background image within the window. + + Common across terminals: + - Ghostty: background-image-position (9 positions) + - Kitty: via centered/clamped modes + - WezTerm: via advanced background config + """ + + TOP_LEFT = "top-left" + TOP_CENTER = "top-center" + TOP_RIGHT = "top-right" + CENTER_LEFT = "center-left" + CENTER = "center" + CENTER_RIGHT = "center-right" + BOTTOM_LEFT = "bottom-left" + BOTTOM_CENTER = "bottom-center" + BOTTOM_RIGHT = "bottom-right" + + class NewTabPosition(Enum): """ Where new tabs are created. @@ -650,6 +696,10 @@ class WindowConfig: decorations: Whether to show window decorations (title bar, etc.) startup_mode: Initial window mode ('windowed', 'maximized', 'fullscreen') dynamic_title: Whether to update window title from shell + background_image: Path to background image file + background_image_opacity: Background image opacity (0.0-1.0, 1.0 = fully visible) + background_image_scale: How to scale/fit the background image + background_image_position: Position of the background image """ columns: int | None = None @@ -661,6 +711,10 @@ class WindowConfig: decorations: bool | None = None startup_mode: str | None = None dynamic_title: bool | None = None + background_image: str | None = None + background_image_opacity: float | None = None + background_image_scale: BackgroundImageScale | None = None + background_image_position: BackgroundImagePosition | None = None def to_dict(self) -> dict: """Convert to dictionary representation.""" @@ -675,15 +729,28 @@ def to_dict(self) -> dict: "decorations", "startup_mode", "dynamic_title", + "background_image", + "background_image_opacity", ]: value = getattr(self, field_name) if value is not None: result[field_name] = value + # Handle enum fields + if self.background_image_scale is not None: + result["background_image_scale"] = self.background_image_scale.value + if self.background_image_position is not None: + result["background_image_position"] = self.background_image_position.value return result @classmethod def from_dict(cls, data: dict) -> "WindowConfig": """Create a WindowConfig from a dictionary.""" + bg_scale = None + if "background_image_scale" in data: + bg_scale = BackgroundImageScale(data["background_image_scale"]) + bg_position = None + if "background_image_position" in data: + bg_position = BackgroundImagePosition(data["background_image_position"]) return cls( columns=data.get("columns"), rows=data.get("rows"), @@ -694,6 +761,10 @@ def from_dict(cls, data: dict) -> "WindowConfig": decorations=data.get("decorations"), startup_mode=data.get("startup_mode"), dynamic_title=data.get("dynamic_title"), + background_image=data.get("background_image"), + background_image_opacity=data.get("background_image_opacity"), + background_image_scale=bg_scale, + background_image_position=bg_position, ) diff --git a/console_cowboy/terminals/ghostty.py b/console_cowboy/terminals/ghostty.py index 0a530c4..0ed80b6 100644 --- a/console_cowboy/terminals/ghostty.py +++ b/console_cowboy/terminals/ghostty.py @@ -9,6 +9,8 @@ from console_cowboy.ctec.schema import ( CTEC, + BackgroundImagePosition, + BackgroundImageScale, BehaviorConfig, ColorScheme, CursorConfig, @@ -116,6 +118,37 @@ class GhosttyAdapter(TerminalAdapter, CursorStyleMixin, ColorMapMixin, ParsingMi "window-padding-y": ("padding_vertical", int), "window-decoration": ("decorations", lambda v: v.lower() != "none"), "window-title-show-all": ("dynamic_title", lambda v: v.lower() == "true"), + "background-image": ("background_image", str), + "background-image-opacity": ("background_image_opacity", float), + } + + # Background image scale mapping (Ghostty: background-image-fit) + BACKGROUND_IMAGE_SCALE_MAP = { + "contain": BackgroundImageScale.CONTAIN, + "cover": BackgroundImageScale.COVER, + "stretch": BackgroundImageScale.STRETCH, + "none": BackgroundImageScale.NONE, + } + + BACKGROUND_IMAGE_SCALE_REVERSE_MAP = { + v: k for k, v in BACKGROUND_IMAGE_SCALE_MAP.items() + } + + # Background image position mapping + BACKGROUND_IMAGE_POSITION_MAP = { + "top-left": BackgroundImagePosition.TOP_LEFT, + "top-center": BackgroundImagePosition.TOP_CENTER, + "top-right": BackgroundImagePosition.TOP_RIGHT, + "center-left": BackgroundImagePosition.CENTER_LEFT, + "center": BackgroundImagePosition.CENTER, + "center-right": BackgroundImagePosition.CENTER_RIGHT, + "bottom-left": BackgroundImagePosition.BOTTOM_LEFT, + "bottom-center": BackgroundImagePosition.BOTTOM_CENTER, + "bottom-right": BackgroundImagePosition.BOTTOM_RIGHT, + } + + BACKGROUND_IMAGE_POSITION_REVERSE_MAP = { + v: k for k, v in BACKGROUND_IMAGE_POSITION_MAP.items() } # Behavior mapping @@ -505,6 +538,26 @@ def on_error(k, v, e): if value.lower() == "true": window.startup_mode = "fullscreen" + # Parse background image scale (fit) and position + elif key == "background-image-fit": + scale = cls.BACKGROUND_IMAGE_SCALE_MAP.get(value.lower()) + if scale: + window.background_image_scale = scale + else: + ctec.add_warning(f"Unknown background-image-fit value: {value}") + + elif key == "background-image-position": + position = cls.BACKGROUND_IMAGE_POSITION_MAP.get(value.lower()) + if position: + window.background_image_position = position + else: + ctec.add_warning(f"Unknown background-image-position: {value}") + + elif key == "background-image-repeat": + # If repeat is true, treat it as tile mode + if value.lower() == "true": + window.background_image_scale = BackgroundImageScale.TILE + # Parse cursor settings elif key == "cursor-style": cursor.style = cls.get_cursor_style(value) @@ -629,7 +682,15 @@ def on_error(k, v, e): ctec.font = font if cursor.style or cursor.blink is not None: ctec.cursor = cursor - if window.columns or window.rows or window.opacity: + if ( + window.columns + or window.rows + or window.opacity + or window.background_image + or window.background_image_opacity is not None + or window.background_image_scale + or window.background_image_position + ): ctec.window = window if ( behavior.shell @@ -771,6 +832,38 @@ def export(cls, ctec: CTEC) -> str: lines.append( f"window-title-show-all = {str(ctec.window.dynamic_title).lower()}" ) + # Export background image settings + if ctec.window.background_image: + lines.append(f"background-image = {ctec.window.background_image}") + if ctec.window.background_image_opacity is not None: + lines.append( + f"background-image-opacity = {ctec.window.background_image_opacity}" + ) + if ctec.window.background_image_scale is not None: + if ctec.window.background_image_scale == BackgroundImageScale.TILE: + # Ghostty uses repeat for tiling + lines.append("background-image-repeat = true") + elif ( + ctec.window.background_image_scale == BackgroundImageScale.CENTERED + ): + # Ghostty doesn't support centered mode, fall back to contain + ctec.add_warning( + "Ghostty does not support 'centered' background image scaling. " + "Using 'contain' as fallback." + ) + lines.append("background-image-fit = contain") + else: + fit_val = cls.BACKGROUND_IMAGE_SCALE_REVERSE_MAP.get( + ctec.window.background_image_scale + ) + if fit_val: + lines.append(f"background-image-fit = {fit_val}") + if ctec.window.background_image_position is not None: + pos_val = cls.BACKGROUND_IMAGE_POSITION_REVERSE_MAP.get( + ctec.window.background_image_position + ) + if pos_val: + lines.append(f"background-image-position = {pos_val}") lines.append("") # Export behavior settings diff --git a/tests/test_ghostty.py b/tests/test_ghostty.py index d262231..3a262c6 100644 --- a/tests/test_ghostty.py +++ b/tests/test_ghostty.py @@ -4,6 +4,8 @@ from console_cowboy.ctec.schema import ( CTEC, + BackgroundImagePosition, + BackgroundImageScale, Color, ColorScheme, CursorConfig, @@ -98,6 +100,144 @@ def test_roundtrip(self): assert restored.font.size == original.font.size assert restored.cursor.style == original.cursor.style + def test_parse_background_image(self): + """Test parsing background image settings from Ghostty config.""" + content = """ +background-image = /path/to/image.png +background-image-opacity = 0.5 +""" + ctec = GhosttyAdapter.parse("test", content=content) + assert ctec.window.background_image == "/path/to/image.png" + assert ctec.window.background_image_opacity == 0.5 + + def test_parse_background_image_scale(self): + """Test parsing background image fit/scale modes.""" + for ghostty_value, expected_scale in [ + ("contain", BackgroundImageScale.CONTAIN), + ("cover", BackgroundImageScale.COVER), + ("stretch", BackgroundImageScale.STRETCH), + ("none", BackgroundImageScale.NONE), + ]: + content = f""" +background-image = /path/to/image.png +background-image-fit = {ghostty_value} +""" + ctec = GhosttyAdapter.parse("test", content=content) + assert ctec.window.background_image_scale == expected_scale + + def test_parse_background_image_position(self): + """Test parsing background image position settings.""" + for position in [ + "top-left", + "top-center", + "top-right", + "center-left", + "center", + "center-right", + "bottom-left", + "bottom-center", + "bottom-right", + ]: + content = f""" +background-image = /path/to/image.png +background-image-position = {position} +""" + ctec = GhosttyAdapter.parse("test", content=content) + assert ctec.window.background_image_position is not None + assert ctec.window.background_image_position.value == position + + def test_parse_background_image_repeat(self): + """Test that background-image-repeat=true sets scale to TILE.""" + content = """ +background-image = /path/to/image.png +background-image-repeat = true +""" + ctec = GhosttyAdapter.parse("test", content=content) + assert ctec.window.background_image_scale == BackgroundImageScale.TILE + + def test_export_background_image(self): + """Test exporting background image settings to Ghostty config.""" + ctec = CTEC( + window=WindowConfig( + background_image="/home/user/wallpaper.png", + background_image_opacity=0.8, + ) + ) + output = GhosttyAdapter.export(ctec) + + assert "background-image = /home/user/wallpaper.png" in output + assert "background-image-opacity = 0.8" in output + + def test_export_background_image_scale_and_position(self): + """Test exporting background image scale and position settings.""" + ctec = CTEC( + window=WindowConfig( + background_image="/path/to/image.png", + background_image_scale=BackgroundImageScale.COVER, + background_image_position=BackgroundImagePosition.CENTER, + ) + ) + output = GhosttyAdapter.export(ctec) + + assert "background-image = /path/to/image.png" in output + assert "background-image-fit = cover" in output + assert "background-image-position = center" in output + + def test_roundtrip_background_image(self): + """Test round-trip conversion of background image settings.""" + content = """ +background-image = /path/to/wallpaper.png +background-image-opacity = 0.75 +background-image-fit = contain +background-image-position = bottom-right +""" + original = GhosttyAdapter.parse("test", content=content) + exported = GhosttyAdapter.export(original) + restored = GhosttyAdapter.parse("test", content=exported) + + assert restored.window.background_image == original.window.background_image + assert ( + restored.window.background_image_opacity + == original.window.background_image_opacity + ) + assert ( + restored.window.background_image_scale + == original.window.background_image_scale + ) + assert ( + restored.window.background_image_position + == original.window.background_image_position + ) + + def test_export_background_image_tile(self): + """Test that BackgroundImageScale.TILE exports as background-image-repeat.""" + ctec = CTEC( + window=WindowConfig( + background_image="/path/to/image.png", + background_image_scale=BackgroundImageScale.TILE, + ) + ) + output = GhosttyAdapter.export(ctec) + + assert "background-image-repeat = true" in output + assert "background-image-fit" not in output + + def test_export_background_image_centered_fallback(self): + """Test that CENTERED scale mode falls back with warning for Ghostty.""" + ctec = CTEC( + window=WindowConfig( + background_image="/path/to/image.png", + background_image_scale=BackgroundImageScale.CENTERED, + ) + ) + output = GhosttyAdapter.export(ctec) + + # Should fall back to contain + assert "background-image-fit = contain" in output + # Should add a warning + assert len(ctec.warnings) == 1 + assert "centered" in ctec.warnings[0].lower() + class TestGhosttyQuickTerminalFeatures: """Tests for Ghostty 1.2.0 quick terminal features (Issue #41).""" From 00e9be03db58c6a28d314266b6e88aa4550f3bb9 Mon Sep 17 00:00:00 2001 From: zetlen Date: Mon, 19 Jan 2026 22:00:29 -0600 Subject: [PATCH 2/7] feat(terminals): add Hyper terminal adapter with JavaScript parsing Add support for the Hyper terminal emulator, an Electron-based terminal with a JavaScript configuration file (.hyper.js). - Add dukpy dependency for sandboxed JavaScript execution - Create HyperAdapter with full parse/export capabilities - Support for font, cursor, window, color scheme, behavior, scroll settings - Parse/export keybindings via keymaps object - Store plugins and Hyper-specific settings as terminal_specific - Add comprehensive test suite with 37 tests - Update terminal registry to include Hyper Co-Authored-By: Claude Opus 4.5 --- console_cowboy/terminals/__init__.py | 3 + console_cowboy/terminals/hyper/__init__.py | 5 + console_cowboy/terminals/hyper/adapter.py | 671 +++++++++++++++++++ console_cowboy/terminals/hyper/javascript.py | 124 ++++ pyproject.toml | 1 + tests/fixtures/hyper/.hyper.js | 83 +++ tests/test_hyper.py | 491 ++++++++++++++ tests/test_terminal_registry.py | 5 +- uv.lock | 56 ++ 9 files changed, 1438 insertions(+), 1 deletion(-) create mode 100644 console_cowboy/terminals/hyper/__init__.py create mode 100644 console_cowboy/terminals/hyper/adapter.py create mode 100644 console_cowboy/terminals/hyper/javascript.py create mode 100644 tests/fixtures/hyper/.hyper.js create mode 100644 tests/test_hyper.py diff --git a/console_cowboy/terminals/__init__.py b/console_cowboy/terminals/__init__.py index 1585dd4..1e0a590 100644 --- a/console_cowboy/terminals/__init__.py +++ b/console_cowboy/terminals/__init__.py @@ -8,6 +8,7 @@ from .alacritty import AlacrittyAdapter from .base import TerminalAdapter, TerminalRegistry from .ghostty import GhosttyAdapter +from .hyper import HyperAdapter from .iterm2 import ITerm2Adapter from .kitty import KittyAdapter from .terminal_app import TerminalAppAdapter @@ -22,6 +23,7 @@ TerminalRegistry.register(WeztermAdapter) TerminalRegistry.register(VSCodeAdapter) TerminalRegistry.register(TerminalAppAdapter) +TerminalRegistry.register(HyperAdapter) __all__ = [ "TerminalAdapter", @@ -33,4 +35,5 @@ "WeztermAdapter", "VSCodeAdapter", "TerminalAppAdapter", + "HyperAdapter", ] diff --git a/console_cowboy/terminals/hyper/__init__.py b/console_cowboy/terminals/hyper/__init__.py new file mode 100644 index 0000000..c286337 --- /dev/null +++ b/console_cowboy/terminals/hyper/__init__.py @@ -0,0 +1,5 @@ +"""Hyper terminal adapter.""" + +from .adapter import HyperAdapter + +__all__ = ["HyperAdapter"] diff --git a/console_cowboy/terminals/hyper/adapter.py b/console_cowboy/terminals/hyper/adapter.py new file mode 100644 index 0000000..f52e625 --- /dev/null +++ b/console_cowboy/terminals/hyper/adapter.py @@ -0,0 +1,671 @@ +""" +Hyper terminal adapter for Console Cowboy. + +Hyper is an Electron-based terminal emulator with a JavaScript configuration file. +The config file is located at: +- macOS: ~/Library/Application Support/Hyper/.hyper.js +- Windows: %APPDATA%/Hyper/.hyper.js +- Linux: ~/.config/Hyper/.hyper.js + +The configuration format is a CommonJS module that exports an object with: +- config: Main configuration options +- plugins: List of plugin names +- localPlugins: List of local plugin paths +- keymaps: Custom keyboard shortcuts +""" + +from pathlib import Path +from typing import Any + +from console_cowboy.ctec.schema import ( + CTEC, + BehaviorConfig, + Color, + ColorScheme, + CursorConfig, + CursorStyle, + FontConfig, + FontWeight, + KeyBinding, + ScrollConfig, + WindowConfig, +) +from console_cowboy.terminals.base import TerminalAdapter + +from .javascript import execute_hyper_config, parse_hyper_color + + +class HyperAdapter(TerminalAdapter): + """Adapter for Hyper terminal emulator.""" + + name = "hyper" + display_name = "Hyper" + description = "Hyper terminal emulator (Electron-based)" + config_extensions = [".js"] + default_config_paths = [ + ".config/Hyper/.hyper.js", # Linux + "Library/Application Support/Hyper/.hyper.js", # macOS + ] + + # Mapping of Hyper cursor shapes to CTEC cursor styles + CURSOR_STYLE_MAP = { + "BLOCK": CursorStyle.BLOCK, + "BEAM": CursorStyle.BEAM, + "UNDERLINE": CursorStyle.UNDERLINE, + } + + # Reverse mapping for export + CURSOR_STYLE_EXPORT_MAP = { + CursorStyle.BLOCK: "BLOCK", + CursorStyle.BEAM: "BEAM", + CursorStyle.UNDERLINE: "UNDERLINE", + } + + # Hyper color palette keys (ANSI 0-15) + ANSI_COLOR_KEYS = [ + "black", + "red", + "green", + "yellow", + "blue", + "magenta", + "cyan", + "white", + "lightBlack", + "lightRed", + "lightGreen", + "lightYellow", + "lightBlue", + "lightMagenta", + "lightCyan", + "lightWhite", + ] + + # Mapping of Hyper ANSI colors to CTEC color scheme fields + ANSI_TO_CTEC_MAP = { + "black": "black", + "red": "red", + "green": "green", + "yellow": "yellow", + "blue": "blue", + "magenta": "magenta", + "cyan": "cyan", + "white": "white", + "lightBlack": "bright_black", + "lightRed": "bright_red", + "lightGreen": "bright_green", + "lightYellow": "bright_yellow", + "lightBlue": "bright_blue", + "lightMagenta": "bright_magenta", + "lightCyan": "bright_cyan", + "lightWhite": "bright_white", + } + + @classmethod + def can_parse(cls, content: str) -> bool: + """Check if this looks like a Hyper config file.""" + # Look for typical Hyper config patterns + indicators = [ + "module.exports", + "fontSize:", + "fontFamily:", + "cursorColor:", + "cursorShape:", + "updateChannel:", + ] + return any(indicator in content for indicator in indicators) + + @classmethod + def parse( + cls, + source: str | Path, + *, + content: str | None = None, + ) -> CTEC: + """Parse a Hyper configuration file into CTEC format.""" + if content is None: + path = Path(source) + if not path.exists(): + raise FileNotFoundError(f"Config file not found: {path}") + content = path.read_text() + + # Execute the JavaScript to get the config object + exports = execute_hyper_config(content) + config = exports.get("config", {}) + + ctec = CTEC(source_terminal="hyper") + + # Parse font settings + font = cls._parse_font(config) + if font: + ctec.font = font + + # Parse cursor settings + cursor = cls._parse_cursor(config) + if cursor: + ctec.cursor = cursor + + # Parse window settings + window = cls._parse_window(config) + if window: + ctec.window = window + + # Parse color scheme + color_scheme = cls._parse_colors(config) + if color_scheme: + ctec.color_scheme = color_scheme + + # Parse behavior settings + behavior = cls._parse_behavior(config) + if behavior: + ctec.behavior = behavior + + # Parse scroll settings + scroll = cls._parse_scroll(config) + if scroll: + ctec.scroll = scroll + + # Parse keybindings + keymaps = exports.get("keymaps", {}) + if keymaps: + ctec.key_bindings = cls._parse_keybindings(keymaps) + + # Store plugins as terminal-specific settings + plugins = exports.get("plugins", []) + if plugins: + ctec.add_terminal_specific("hyper", "plugins", plugins) + + local_plugins = exports.get("localPlugins", []) + if local_plugins: + ctec.add_terminal_specific("hyper", "localPlugins", local_plugins) + + # Store Hyper-specific settings + cls._parse_terminal_specific(config, ctec) + + return ctec + + @classmethod + def _parse_font(cls, config: dict) -> FontConfig | None: + """Parse font configuration from Hyper config.""" + font = FontConfig() + has_values = False + + if "fontFamily" in config: + # Hyper fontFamily is a CSS font-family string with fallbacks + family_str = config["fontFamily"] + families = [f.strip().strip("\"'") for f in family_str.split(",")] + if families: + font.family = families[0] + if len(families) > 1: + font.fallback_fonts = families[1:] + has_values = True + + if "fontSize" in config: + font.size = float(config["fontSize"]) + has_values = True + + if "fontWeight" in config: + weight = config["fontWeight"] + if weight == "bold": + font.weight = FontWeight.BOLD + elif weight == "normal": + font.weight = FontWeight.REGULAR + elif isinstance(weight, int): + # Numeric weight (100-900) + try: + font.weight = FontWeight(weight) + except ValueError: + pass + has_values = True + + if "fontWeightBold" in config: + # Store as terminal-specific since CTEC doesn't have separate bold weight + pass # Handled in _parse_terminal_specific + + if "lineHeight" in config: + font.line_height = float(config["lineHeight"]) + has_values = True + + if "letterSpacing" in config: + # Hyper uses letter-spacing in CSS units + # Store as terminal-specific since CTEC uses cell_width multiplier + pass # Handled in _parse_terminal_specific + + return font if has_values else None + + @classmethod + def _parse_cursor(cls, config: dict) -> CursorConfig | None: + """Parse cursor configuration from Hyper config.""" + cursor = CursorConfig() + has_values = False + + if "cursorShape" in config: + shape = config["cursorShape"].upper() + cursor.style = cls.CURSOR_STYLE_MAP.get(shape, CursorStyle.BLOCK) + has_values = True + + if "cursorBlink" in config: + cursor.blink = bool(config["cursorBlink"]) + has_values = True + + return cursor if has_values else None + + @classmethod + def _parse_window(cls, config: dict) -> WindowConfig | None: + """Parse window configuration from Hyper config.""" + window = WindowConfig() + has_values = False + + if "windowSize" in config: + size = config["windowSize"] + if isinstance(size, (list, tuple)) and len(size) >= 2: + # windowSize is [width, height] in pixels + # We can't directly convert to columns/rows without knowing font size + # Store as terminal-specific + pass # Handled in _parse_terminal_specific + has_values = True + + if "padding" in config: + # Hyper padding is CSS-style: "12px 14px" or "12px" + padding_str = config["padding"] + parts = padding_str.replace("px", "").split() + if len(parts) == 1: + window.padding_horizontal = int(parts[0]) + window.padding_vertical = int(parts[0]) + elif len(parts) >= 2: + window.padding_vertical = int(parts[0]) + window.padding_horizontal = int(parts[1]) + has_values = True + + return window if has_values else None + + @classmethod + def _parse_colors(cls, config: dict) -> ColorScheme | None: + """Parse color scheme from Hyper config.""" + scheme = ColorScheme() + has_values = False + + # Foreground color + if "foregroundColor" in config: + rgb = parse_hyper_color(config["foregroundColor"]) + if rgb: + scheme.foreground = Color(*rgb) + has_values = True + + # Background color + if "backgroundColor" in config: + rgb = parse_hyper_color(config["backgroundColor"]) + if rgb: + scheme.background = Color(*rgb) + has_values = True + + # Cursor color + if "cursorColor" in config: + rgb = parse_hyper_color(config["cursorColor"]) + if rgb: + scheme.cursor = Color(*rgb) + has_values = True + + # Cursor accent color (text under cursor) + if "cursorAccentColor" in config: + rgb = parse_hyper_color(config["cursorAccentColor"]) + if rgb: + scheme.cursor_text = Color(*rgb) + has_values = True + + # Selection color + if "selectionColor" in config: + rgb = parse_hyper_color(config["selectionColor"]) + if rgb: + scheme.selection = Color(*rgb) + has_values = True + + # Border color (not directly in CTEC, store as terminal-specific) + + # ANSI colors palette + colors = config.get("colors", {}) + for hyper_key, ctec_key in cls.ANSI_TO_CTEC_MAP.items(): + if hyper_key in colors: + rgb = parse_hyper_color(colors[hyper_key]) + if rgb: + setattr(scheme, ctec_key, Color(*rgb)) + has_values = True + + return scheme if has_values else None + + @classmethod + def _parse_behavior(cls, config: dict) -> BehaviorConfig | None: + """Parse behavior configuration from Hyper config.""" + behavior = BehaviorConfig() + has_values = False + + if "shell" in config and config["shell"]: + behavior.shell = config["shell"] + has_values = True + + if "shellArgs" in config: + args = config["shellArgs"] + if isinstance(args, list): + behavior.shell_args = args + has_values = True + + if "env" in config: + env = config["env"] + if isinstance(env, dict): + behavior.environment_variables = env + has_values = True + + if "copyOnSelect" in config: + behavior.copy_on_select = bool(config["copyOnSelect"]) + has_values = True + + return behavior if has_values else None + + @classmethod + def _parse_scroll(cls, config: dict) -> ScrollConfig | None: + """Parse scroll configuration from Hyper config.""" + if "scrollback" in config: + lines = int(config["scrollback"]) + return ScrollConfig.from_lines(lines) + return None + + @classmethod + def _parse_keybindings(cls, keymaps: dict) -> list[KeyBinding]: + """Parse keybindings from Hyper keymaps.""" + bindings = [] + + for action, key_combo in keymaps.items(): + if not key_combo: + continue + + # Parse the key combo (e.g., "cmd+alt+o") + parts = key_combo.lower().split("+") + key = parts[-1] if parts else "" + mods = parts[:-1] if len(parts) > 1 else [] + + # Normalize modifier names + mod_map = { + "cmd": "super", + "command": "super", + "ctrl": "ctrl", + "control": "ctrl", + "alt": "alt", + "option": "alt", + "shift": "shift", + } + normalized_mods = [mod_map.get(m, m) for m in mods] + + bindings.append( + KeyBinding( + action=action, + key=key, + mods=normalized_mods, + ) + ) + + return bindings + + @classmethod + def _parse_terminal_specific(cls, config: dict, ctec: CTEC) -> None: + """Store Hyper-specific settings that don't map to CTEC.""" + specific_keys = [ + "updateChannel", + "fontWeightBold", + "letterSpacing", + "uiFontFamily", + "windowSize", + "borderColor", + "css", + "termCSS", + "showHamburgerMenu", + "showWindowControls", + "quickEdit", + "macOptionSelectionMode", + "webGLRenderer", + "webLinksActivationKey", + "defaultSSHApp", + "modifierKeys", + ] + + for key in specific_keys: + if key in config and config[key] is not None: + ctec.add_terminal_specific("hyper", key, config[key]) + + @classmethod + def export(cls, ctec: CTEC) -> str: + """Export CTEC configuration to Hyper .hyper.js format.""" + config_items: list[str] = [] + + # Font settings + if ctec.font: + cls._export_font(ctec.font, config_items) + + # Cursor settings + if ctec.cursor: + cls._export_cursor(ctec.cursor, config_items) + + # Window settings + if ctec.window: + cls._export_window(ctec.window, config_items) + + # Color scheme + if ctec.color_scheme: + cls._export_colors(ctec.color_scheme, config_items) + + # Behavior settings + if ctec.behavior: + cls._export_behavior(ctec.behavior, config_items) + + # Scroll settings + if ctec.scroll: + cls._export_scroll(ctec.scroll, config_items) + + # Terminal-specific settings + cls._export_terminal_specific(ctec, config_items) + + # Keybindings + keymaps = cls._export_keybindings(ctec.key_bindings) + + # Plugins + plugins = ctec.get_terminal_specific("hyper", "plugins") + plugins_str = cls._format_js_array(plugins) if plugins else "[]" + + local_plugins = ctec.get_terminal_specific("hyper", "localPlugins") + local_plugins_str = ( + cls._format_js_array(local_plugins) if local_plugins else "[]" + ) + + # Build the config object + config_body = ",\n ".join(config_items) if config_items else "" + + output = f"""// Hyper configuration +// Generated by Console Cowboy +// See https://hyper.is#cfg for all options + +module.exports = {{ + config: {{ + {config_body} + }}, + + plugins: {plugins_str}, + + localPlugins: {local_plugins_str}, + + keymaps: {keymaps} +}}; +""" + + return output + + @classmethod + def _export_font(cls, font: FontConfig, items: list[str]) -> None: + """Export font configuration to Hyper format.""" + if font.family: + # Build font-family string with fallbacks + families = [font.family] + if font.fallback_fonts: + families.extend(font.fallback_fonts) + + if len(families) == 1: + # Single font - use simple format + items.append(f"fontFamily: '{families[0]}'") + else: + # Multiple fonts - quote names with spaces, use CSS format + family_str = ", ".join(f'"{f}"' if " " in f else f for f in families) + items.append(f"fontFamily: '{family_str}'") + + if font.size is not None: + items.append(f"fontSize: {int(font.size)}") + + if font.weight is not None: + if font.weight == FontWeight.BOLD: + items.append("fontWeight: 'bold'") + elif font.weight == FontWeight.REGULAR: + items.append("fontWeight: 'normal'") + else: + items.append(f"fontWeight: {font.weight.value}") + + if font.line_height is not None: + items.append(f"lineHeight: {font.line_height}") + + @classmethod + def _export_cursor(cls, cursor: CursorConfig, items: list[str]) -> None: + """Export cursor configuration to Hyper format.""" + if cursor.style is not None: + shape = cls.CURSOR_STYLE_EXPORT_MAP.get(cursor.style, "BLOCK") + items.append(f"cursorShape: '{shape}'") + + if cursor.blink is not None: + items.append(f"cursorBlink: {str(cursor.blink).lower()}") + + @classmethod + def _export_window(cls, window: WindowConfig, items: list[str]) -> None: + """Export window configuration to Hyper format.""" + if window.padding_horizontal is not None or window.padding_vertical is not None: + h = window.padding_horizontal or 0 + v = window.padding_vertical or 0 + if h == v: + items.append(f"padding: '{h}px'") + else: + items.append(f"padding: '{v}px {h}px'") + + @classmethod + def _export_colors(cls, scheme: ColorScheme, items: list[str]) -> None: + """Export color scheme to Hyper format.""" + if scheme.foreground: + items.append(f"foregroundColor: '{scheme.foreground.to_hex()}'") + + if scheme.background: + items.append(f"backgroundColor: '{scheme.background.to_hex()}'") + + if scheme.cursor: + items.append(f"cursorColor: '{scheme.cursor.to_hex()}'") + + if scheme.cursor_text: + items.append(f"cursorAccentColor: '{scheme.cursor_text.to_hex()}'") + + if scheme.selection: + items.append(f"selectionColor: '{scheme.selection.to_hex()}'") + + # ANSI colors + ansi_colors: dict[str, str] = {} + ctec_to_ansi = {v: k for k, v in cls.ANSI_TO_CTEC_MAP.items()} + + for ctec_key, hyper_key in ctec_to_ansi.items(): + color = getattr(scheme, ctec_key, None) + if color: + ansi_colors[hyper_key] = color.to_hex() + + if ansi_colors: + colors_items = [f"{k}: '{v}'" for k, v in ansi_colors.items()] + colors_str = ",\n ".join(colors_items) + items.append(f"colors: {{\n {colors_str}\n }}") + + @classmethod + def _export_behavior(cls, behavior: BehaviorConfig, items: list[str]) -> None: + """Export behavior configuration to Hyper format.""" + if behavior.shell: + items.append(f"shell: '{behavior.shell}'") + + if behavior.shell_args: + args_str = ", ".join(f"'{a}'" for a in behavior.shell_args) + items.append(f"shellArgs: [{args_str}]") + + if behavior.environment_variables: + env_items = [ + f"{k}: '{v}'" for k, v in behavior.environment_variables.items() + ] + env_str = ", ".join(env_items) + items.append(f"env: {{ {env_str} }}") + + if behavior.copy_on_select is not None: + items.append(f"copyOnSelect: {str(behavior.copy_on_select).lower()}") + + @classmethod + def _export_scroll(cls, scroll: ScrollConfig, items: list[str]) -> None: + """Export scroll configuration to Hyper format.""" + lines = scroll.get_effective_lines(default=1000) + items.append(f"scrollback: {lines}") + + @classmethod + def _export_terminal_specific(cls, ctec: CTEC, items: list[str]) -> None: + """Export terminal-specific settings back to Hyper.""" + for setting in ctec.get_terminal_specific("hyper"): + if not isinstance(setting, list): # Skip already processed ones + key = setting.key + value = setting.value + + # Skip plugins and localPlugins (handled separately) + if key in ("plugins", "localPlugins"): + continue + + items.append(f"{key}: {cls._format_js_value(value)}") + + @classmethod + def _export_keybindings(cls, bindings: list[KeyBinding]) -> str: + """Export keybindings to Hyper keymaps format.""" + if not bindings: + return "{}" + + keymap_items = [] + for binding in bindings: + # Reverse normalize modifiers + mod_map = { + "super": "cmd", + "ctrl": "ctrl", + "alt": "alt", + "shift": "shift", + } + mods = [mod_map.get(m, m) for m in binding.mods] + key_combo = "+".join(mods + [binding.key]) + keymap_items.append(f"'{binding.action}': '{key_combo}'") + + keymaps_str = ",\n ".join(keymap_items) + return f"{{\n {keymaps_str}\n }}" + + @classmethod + def _format_js_value(cls, value: Any) -> str: + """Format a Python value as JavaScript.""" + if value is None: + return "null" + elif isinstance(value, bool): + return "true" if value else "false" + elif isinstance(value, str): + return f"'{value}'" + elif isinstance(value, (int, float)): + return str(value) + elif isinstance(value, list): + return cls._format_js_array(value) + elif isinstance(value, dict): + items = [f"{k}: {cls._format_js_value(v)}" for k, v in value.items()] + return "{ " + ", ".join(items) + " }" + else: + return f"'{value}'" + + @classmethod + def _format_js_array(cls, arr: list | Any) -> str: + """Format a Python list as JavaScript array.""" + if not isinstance(arr, list): + return "[]" + items = [cls._format_js_value(item) for item in arr] + return "[" + ", ".join(items) + "]" diff --git a/console_cowboy/terminals/hyper/javascript.py b/console_cowboy/terminals/hyper/javascript.py new file mode 100644 index 0000000..2b5b295 --- /dev/null +++ b/console_cowboy/terminals/hyper/javascript.py @@ -0,0 +1,124 @@ +""" +JavaScript runtime support for parsing Hyper configuration files. + +This module uses dukpy to embed a JavaScript interpreter and execute Hyper +config files with a mock module system that captures the exported configuration. +""" + +from typing import Any + +import dukpy # type: ignore[import-untyped] + + +def execute_hyper_config(js_source: str) -> dict[str, Any]: + """ + Execute a Hyper JavaScript config and return the captured configuration. + + The JavaScript environment is sandboxed to prevent arbitrary code execution. + Only the module.exports pattern is supported. + + Args: + js_source: The JavaScript source code to execute + + Returns: + A dict containing the config values from module.exports + + Raises: + ValueError: If the JavaScript code fails to execute or doesn't export config + """ + # Wrap the user's code to capture module.exports + # We create a mock module object and execute the user's code, + # then return the exports + wrapper = """ + (function() { + var module = { exports: {} }; + var exports = module.exports; + + // User's code goes here + %s + + return module.exports; + })() + """ + + try: + result = dukpy.evaljs(wrapper % js_source) + except Exception as e: + raise ValueError(f"Failed to execute Hyper config: {e}") from e + + if result is None: + raise ValueError("Hyper config did not export any configuration") + + if not isinstance(result, dict): + raise ValueError( + f"Hyper config must export an object, got {type(result).__name__}" + ) + + # Check if exports is actually empty (no config was assigned) + if not result: + raise ValueError("Hyper config did not export any configuration") + + return result + + +def parse_hyper_color(color_str: str) -> tuple[int, int, int] | None: + """ + Parse a Hyper color string into RGB values. + + Supports: + - Hex colors: #rgb, #rrggbb + - rgba() colors: rgba(r, g, b, a) + + Args: + color_str: The color string to parse + + Returns: + Tuple of (r, g, b) values, or None if parsing fails + """ + if not color_str: + return None + + color_str = color_str.strip() + + # Handle hex colors + if color_str.startswith("#"): + hex_part = color_str[1:] + if len(hex_part) == 3: + # #rgb -> #rrggbb + hex_part = "".join(c * 2 for c in hex_part) + if len(hex_part) == 6: + try: + r = int(hex_part[0:2], 16) + g = int(hex_part[2:4], 16) + b = int(hex_part[4:6], 16) + return (r, g, b) + except ValueError: + return None + + # Handle rgba() colors + if color_str.startswith("rgba(") and color_str.endswith(")"): + inner = color_str[5:-1] + parts = [p.strip() for p in inner.split(",")] + if len(parts) >= 3: + try: + r = int(parts[0]) + g = int(parts[1]) + b = int(parts[2]) + return (r, g, b) + except ValueError: + return None + + # Handle rgb() colors + if color_str.startswith("rgb(") and color_str.endswith(")"): + inner = color_str[4:-1] + parts = [p.strip() for p in inner.split(",")] + if len(parts) >= 3: + try: + r = int(parts[0]) + g = int(parts[1]) + b = int(parts[2]) + return (r, g, b) + except ValueError: + return None + + return None diff --git a/pyproject.toml b/pyproject.toml index 064b2de..d088060 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,7 @@ classifiers = [ ] dependencies = [ "click", + "dukpy>=0.3", "lupa>=2.0", "pyyaml>=6.0", "tomli>=2.0", diff --git a/tests/fixtures/hyper/.hyper.js b/tests/fixtures/hyper/.hyper.js new file mode 100644 index 0000000..dc86372 --- /dev/null +++ b/tests/fixtures/hyper/.hyper.js @@ -0,0 +1,83 @@ +// Hyper configuration file for testing + +module.exports = { + config: { + // Update channel + updateChannel: 'stable', + + // Font settings + fontSize: 14, + fontFamily: 'JetBrains Mono, Menlo, "DejaVu Sans Mono", monospace', + fontWeight: 'normal', + fontWeightBold: 'bold', + lineHeight: 1.2, + letterSpacing: 0, + + // Cursor settings + cursorColor: 'rgba(248,28,229,0.8)', + cursorAccentColor: '#000', + cursorShape: 'BLOCK', + cursorBlink: true, + + // Colors + foregroundColor: '#c5c8c6', + backgroundColor: '#1d1f21', + selectionColor: 'rgba(248,28,229,0.3)', + borderColor: '#333', + + // ANSI color palette + colors: { + black: '#282a2e', + red: '#a54242', + green: '#8c9440', + yellow: '#de935f', + blue: '#5f819d', + magenta: '#85678f', + cyan: '#5e8d87', + white: '#707880', + lightBlack: '#373b41', + lightRed: '#cc6666', + lightGreen: '#b5bd68', + lightYellow: '#f0c674', + lightBlue: '#81a2be', + lightMagenta: '#b294bb', + lightCyan: '#8abeb7', + lightWhite: '#c5c8c6' + }, + + // Shell settings + shell: '/bin/zsh', + shellArgs: ['--login'], + + // Environment variables + env: { + TERM: 'xterm-256color' + }, + + // Window settings + padding: '12px 14px', + scrollback: 10000, + + // Behavior settings + copyOnSelect: false, + + // Hyper-specific settings + webGLRenderer: true + }, + + // Plugin list + plugins: [ + 'hyper-snazzy', + 'hyper-tabs-enhanced' + ], + + // Local plugins + localPlugins: [], + + // Custom keymaps + keymaps: { + 'window:devtools': 'cmd+alt+o', + 'tab:new': 'cmd+t', + 'pane:splitVertical': 'cmd+d' + } +}; diff --git a/tests/test_hyper.py b/tests/test_hyper.py new file mode 100644 index 0000000..2d39b18 --- /dev/null +++ b/tests/test_hyper.py @@ -0,0 +1,491 @@ +"""Tests for the Hyper adapter.""" + +from pathlib import Path + +from console_cowboy.ctec.schema import ( + CTEC, + BehaviorConfig, + Color, + ColorScheme, + CursorConfig, + CursorStyle, + FontConfig, + FontWeight, + KeyBinding, + ScrollConfig, + WindowConfig, +) +from console_cowboy.terminals import HyperAdapter + +FIXTURES_DIR = Path(__file__).parent / "fixtures" + + +class TestHyperAdapter: + """Tests for the Hyper adapter.""" + + def test_adapter_metadata(self): + assert HyperAdapter.name == "hyper" + assert HyperAdapter.display_name == "Hyper" + assert ".js" in HyperAdapter.config_extensions + assert ".config/Hyper/.hyper.js" in HyperAdapter.default_config_paths + + def test_can_parse(self): + """Test format detection.""" + hyper_content = """ +module.exports = { + config: { + fontSize: 14, + fontFamily: 'Menlo' + } +}; +""" + assert HyperAdapter.can_parse(hyper_content) is True + + # Non-Hyper content + assert HyperAdapter.can_parse("font-family = Menlo") is False + + def test_parse_fixture(self): + """Test parsing the full fixture file.""" + config_path = FIXTURES_DIR / "hyper" / ".hyper.js" + ctec = HyperAdapter.parse(config_path) + + assert ctec.source_terminal == "hyper" + assert ctec.font.family == "JetBrains Mono" + assert ctec.font.size == 14.0 + assert ctec.font.line_height == 1.2 + assert ctec.cursor.style == CursorStyle.BLOCK + assert ctec.cursor.blink is True + + def test_parse_font(self): + """Test parsing font settings.""" + content = """ +module.exports = { + config: { + fontSize: 16, + fontFamily: 'Fira Code, Monaco, monospace', + fontWeight: 'bold', + lineHeight: 1.5 + } +}; +""" + ctec = HyperAdapter.parse("test.js", content=content) + + assert ctec.font.family == "Fira Code" + assert ctec.font.fallback_fonts == ["Monaco", "monospace"] + assert ctec.font.size == 16.0 + assert ctec.font.weight == FontWeight.BOLD + assert ctec.font.line_height == 1.5 + + def test_parse_cursor(self): + """Test parsing cursor settings.""" + content = """ +module.exports = { + config: { + cursorShape: 'BEAM', + cursorBlink: false + } +}; +""" + ctec = HyperAdapter.parse("test.js", content=content) + + assert ctec.cursor.style == CursorStyle.BEAM + assert ctec.cursor.blink is False + + def test_parse_cursor_underline(self): + """Test parsing underline cursor style.""" + content = """ +module.exports = { + config: { + cursorShape: 'UNDERLINE', + cursorBlink: true + } +}; +""" + ctec = HyperAdapter.parse("test.js", content=content) + + assert ctec.cursor.style == CursorStyle.UNDERLINE + assert ctec.cursor.blink is True + + def test_parse_colors(self): + """Test parsing color scheme.""" + config_path = FIXTURES_DIR / "hyper" / ".hyper.js" + ctec = HyperAdapter.parse(config_path) + + assert ctec.color_scheme is not None + # Foreground: #c5c8c6 + assert ctec.color_scheme.foreground.r == 197 + assert ctec.color_scheme.foreground.g == 200 + assert ctec.color_scheme.foreground.b == 198 + # Background: #1d1f21 + assert ctec.color_scheme.background.r == 29 + assert ctec.color_scheme.background.g == 31 + assert ctec.color_scheme.background.b == 33 + + def test_parse_ansi_colors(self): + """Test parsing ANSI color palette.""" + config_path = FIXTURES_DIR / "hyper" / ".hyper.js" + ctec = HyperAdapter.parse(config_path) + + assert ctec.color_scheme is not None + # black: #282a2e + assert ctec.color_scheme.black.r == 40 + assert ctec.color_scheme.black.g == 42 + assert ctec.color_scheme.black.b == 46 + # lightRed -> bright_red: #cc6666 + assert ctec.color_scheme.bright_red.r == 204 + assert ctec.color_scheme.bright_red.g == 102 + assert ctec.color_scheme.bright_red.b == 102 + + def test_parse_behavior(self): + """Test parsing behavior settings.""" + config_path = FIXTURES_DIR / "hyper" / ".hyper.js" + ctec = HyperAdapter.parse(config_path) + + assert ctec.behavior.shell == "/bin/zsh" + assert ctec.behavior.shell_args == ["--login"] + assert ctec.behavior.environment_variables == {"TERM": "xterm-256color"} + assert ctec.behavior.copy_on_select is False + + def test_parse_scroll(self): + """Test parsing scroll settings.""" + config_path = FIXTURES_DIR / "hyper" / ".hyper.js" + ctec = HyperAdapter.parse(config_path) + + assert ctec.scroll is not None + assert ctec.scroll.lines == 10000 + + def test_parse_window_padding(self): + """Test parsing window padding.""" + config_path = FIXTURES_DIR / "hyper" / ".hyper.js" + ctec = HyperAdapter.parse(config_path) + + assert ctec.window is not None + assert ctec.window.padding_vertical == 12 + assert ctec.window.padding_horizontal == 14 + + def test_parse_keybindings(self): + """Test parsing keybindings.""" + config_path = FIXTURES_DIR / "hyper" / ".hyper.js" + ctec = HyperAdapter.parse(config_path) + + assert len(ctec.key_bindings) == 3 + + # Find the devtools binding + devtools = next( + (kb for kb in ctec.key_bindings if kb.action == "window:devtools"), None + ) + assert devtools is not None + assert devtools.key == "o" + assert "super" in devtools.mods + assert "alt" in devtools.mods + + def test_parse_plugins(self): + """Test parsing plugins as terminal-specific settings.""" + config_path = FIXTURES_DIR / "hyper" / ".hyper.js" + ctec = HyperAdapter.parse(config_path) + + plugins = ctec.get_terminal_specific("hyper", "plugins") + assert plugins is not None + assert "hyper-snazzy" in plugins + assert "hyper-tabs-enhanced" in plugins + + def test_parse_terminal_specific(self): + """Test parsing Hyper-specific settings.""" + config_path = FIXTURES_DIR / "hyper" / ".hyper.js" + ctec = HyperAdapter.parse(config_path) + + update_channel = ctec.get_terminal_specific("hyper", "updateChannel") + assert update_channel == "stable" + + webgl = ctec.get_terminal_specific("hyper", "webGLRenderer") + assert webgl is True + + def test_export_basic(self): + """Test basic export functionality.""" + ctec = CTEC( + font=FontConfig(family="Fira Code", size=12.0), + cursor=CursorConfig(style=CursorStyle.BEAM, blink=True), + ) + output = HyperAdapter.export(ctec) + + assert "fontFamily: 'Fira Code'" in output + assert "fontSize: 12" in output + assert "cursorShape: 'BEAM'" in output + assert "cursorBlink: true" in output + assert "module.exports" in output + + def test_export_colors(self): + """Test exporting color scheme.""" + ctec = CTEC( + color_scheme=ColorScheme( + foreground=Color(255, 255, 255), + background=Color(0, 0, 0), + cursor=Color(255, 0, 0), + ) + ) + output = HyperAdapter.export(ctec) + + assert "foregroundColor: '#ffffff'" in output + assert "backgroundColor: '#000000'" in output + assert "cursorColor: '#ff0000'" in output + + def test_export_ansi_colors(self): + """Test exporting ANSI color palette.""" + ctec = CTEC( + color_scheme=ColorScheme( + black=Color(40, 42, 46), + red=Color(165, 66, 66), + bright_red=Color(204, 102, 102), + ) + ) + output = HyperAdapter.export(ctec) + + assert "colors:" in output + assert "black: '#282a2e'" in output + assert "red: '#a54242'" in output + assert "lightRed: '#cc6666'" in output + + def test_export_behavior(self): + """Test exporting behavior settings.""" + ctec = CTEC( + behavior=BehaviorConfig( + shell="/bin/bash", + shell_args=["-l", "-c", "echo hello"], + copy_on_select=True, + ) + ) + output = HyperAdapter.export(ctec) + + assert "shell: '/bin/bash'" in output + assert "shellArgs: ['-l', '-c', 'echo hello']" in output + assert "copyOnSelect: true" in output + + def test_export_scroll(self): + """Test exporting scroll settings.""" + ctec = CTEC(scroll=ScrollConfig(lines=5000)) + output = HyperAdapter.export(ctec) + + assert "scrollback: 5000" in output + + def test_export_keybindings(self): + """Test exporting keybindings.""" + ctec = CTEC( + key_bindings=[ + KeyBinding(action="tab:new", key="t", mods=["super"]), + KeyBinding(action="window:close", key="w", mods=["super", "shift"]), + ] + ) + output = HyperAdapter.export(ctec) + + assert "'tab:new': 'cmd+t'" in output + assert "'window:close': 'cmd+shift+w'" in output + + def test_export_plugins(self): + """Test exporting plugins.""" + ctec = CTEC() + ctec.add_terminal_specific("hyper", "plugins", ["hyper-snazzy", "hypercwd"]) + output = HyperAdapter.export(ctec) + + assert "plugins: ['hyper-snazzy', 'hypercwd']" in output + + def test_export_font_with_fallbacks(self): + """Test exporting font with fallback fonts.""" + ctec = CTEC( + font=FontConfig( + family="JetBrains Mono", + fallback_fonts=["Menlo", "DejaVu Sans Mono", "monospace"], + ) + ) + output = HyperAdapter.export(ctec) + + assert "fontFamily:" in output + assert "JetBrains Mono" in output + assert "Menlo" in output + + def test_export_window_padding(self): + """Test exporting window padding.""" + ctec = CTEC( + window=WindowConfig( + padding_horizontal=14, + padding_vertical=12, + ) + ) + output = HyperAdapter.export(ctec) + + assert "padding: '12px 14px'" in output + + def test_export_equal_padding(self): + """Test exporting equal horizontal and vertical padding.""" + ctec = CTEC( + window=WindowConfig( + padding_horizontal=10, + padding_vertical=10, + ) + ) + output = HyperAdapter.export(ctec) + + assert "padding: '10px'" in output + + def test_roundtrip(self): + """Test round-trip conversion.""" + config_path = FIXTURES_DIR / "hyper" / ".hyper.js" + original = HyperAdapter.parse(config_path) + + # Export and re-parse + exported = HyperAdapter.export(original) + restored = HyperAdapter.parse("test.js", content=exported) + + # Check key values are preserved + assert restored.font.family == original.font.family + assert restored.font.size == original.font.size + assert restored.cursor.style == original.cursor.style + assert restored.cursor.blink == original.cursor.blink + assert restored.behavior.shell == original.behavior.shell + assert restored.scroll.lines == original.scroll.lines + + def test_parse_rgba_colors(self): + """Test parsing rgba() color values.""" + content = """ +module.exports = { + config: { + cursorColor: 'rgba(248, 28, 229, 0.8)', + selectionColor: 'rgba(100, 150, 200, 0.5)' + } +}; +""" + ctec = HyperAdapter.parse("test.js", content=content) + + assert ctec.color_scheme is not None + assert ctec.color_scheme.cursor.r == 248 + assert ctec.color_scheme.cursor.g == 28 + assert ctec.color_scheme.cursor.b == 229 + assert ctec.color_scheme.selection.r == 100 + assert ctec.color_scheme.selection.g == 150 + assert ctec.color_scheme.selection.b == 200 + + def test_parse_numeric_font_weight(self): + """Test parsing numeric font weight.""" + content = """ +module.exports = { + config: { + fontWeight: 600 + } +}; +""" + ctec = HyperAdapter.parse("test.js", content=content) + + assert ctec.font.weight == FontWeight.SEMI_BOLD + + def test_parse_single_padding(self): + """Test parsing single value padding.""" + content = """ +module.exports = { + config: { + padding: '16px' + } +}; +""" + ctec = HyperAdapter.parse("test.js", content=content) + + assert ctec.window.padding_horizontal == 16 + assert ctec.window.padding_vertical == 16 + + +class TestHyperJavaScriptParser: + """Tests for the JavaScript parser module.""" + + def test_parse_simple_config(self): + """Test parsing a simple configuration.""" + from console_cowboy.terminals.hyper.javascript import execute_hyper_config + + js_source = """ +module.exports = { + config: { + fontSize: 14, + fontFamily: 'Menlo' + } +}; +""" + result = execute_hyper_config(js_source) + assert result["config"]["fontSize"] == 14 + assert result["config"]["fontFamily"] == "Menlo" + + def test_parse_nested_objects(self): + """Test parsing nested objects.""" + from console_cowboy.terminals.hyper.javascript import execute_hyper_config + + js_source = """ +module.exports = { + config: { + colors: { + black: '#000000', + white: '#ffffff' + } + } +}; +""" + result = execute_hyper_config(js_source) + assert result["config"]["colors"]["black"] == "#000000" + assert result["config"]["colors"]["white"] == "#ffffff" + + def test_parse_arrays(self): + """Test parsing arrays.""" + from console_cowboy.terminals.hyper.javascript import execute_hyper_config + + js_source = """ +module.exports = { + plugins: ['plugin1', 'plugin2'], + config: {} +}; +""" + result = execute_hyper_config(js_source) + assert result["plugins"] == ["plugin1", "plugin2"] + + def test_parse_color_hex(self): + """Test parsing hex colors.""" + from console_cowboy.terminals.hyper.javascript import parse_hyper_color + + assert parse_hyper_color("#ff0000") == (255, 0, 0) + assert parse_hyper_color("#00ff00") == (0, 255, 0) + assert parse_hyper_color("#0000ff") == (0, 0, 255) + assert parse_hyper_color("#fff") == (255, 255, 255) + assert parse_hyper_color("#000") == (0, 0, 0) + + def test_parse_color_rgba(self): + """Test parsing rgba colors.""" + from console_cowboy.terminals.hyper.javascript import parse_hyper_color + + assert parse_hyper_color("rgba(255, 0, 0, 0.5)") == (255, 0, 0) + assert parse_hyper_color("rgba(100, 150, 200, 1.0)") == (100, 150, 200) + + def test_parse_color_rgb(self): + """Test parsing rgb colors.""" + from console_cowboy.terminals.hyper.javascript import parse_hyper_color + + assert parse_hyper_color("rgb(255, 128, 64)") == (255, 128, 64) + + def test_parse_invalid_color(self): + """Test parsing invalid colors returns None.""" + from console_cowboy.terminals.hyper.javascript import parse_hyper_color + + assert parse_hyper_color("invalid") is None + assert parse_hyper_color("") is None + assert parse_hyper_color("#gggggg") is None + + def test_invalid_js_raises_error(self): + """Test that invalid JavaScript raises an error.""" + import pytest + + from console_cowboy.terminals.hyper.javascript import execute_hyper_config + + with pytest.raises(ValueError, match="Failed to execute"): + execute_hyper_config("this is not valid javascript {{{{") + + def test_missing_exports_raises_error(self): + """Test that missing exports raises an error.""" + import pytest + + from console_cowboy.terminals.hyper.javascript import execute_hyper_config + + with pytest.raises(ValueError, match="did not export"): + execute_hyper_config("var x = 1;") diff --git a/tests/test_terminal_registry.py b/tests/test_terminal_registry.py index 813cde2..38c7eeb 100644 --- a/tests/test_terminal_registry.py +++ b/tests/test_terminal_registry.py @@ -2,6 +2,7 @@ from console_cowboy.terminals import ( GhosttyAdapter, + HyperAdapter, ITerm2Adapter, TerminalAppAdapter, TerminalRegistry, @@ -21,6 +22,7 @@ def test_get_all_terminals(self): assert "wezterm" in names assert "vscode" in names assert "terminal_app" in names + assert "hyper" in names def test_get_terminal_by_name(self): adapter = TerminalRegistry.get("ghostty") @@ -36,7 +38,8 @@ def test_get_unknown_terminal(self): def test_list_terminals(self): terminals = TerminalRegistry.list_terminals() - assert len(terminals) == 7 + assert len(terminals) == 8 assert ITerm2Adapter in terminals assert VSCodeAdapter in terminals assert TerminalAppAdapter in terminals + assert HyperAdapter in terminals diff --git a/uv.lock b/uv.lock index 056c3bd..97ea9e7 100644 --- a/uv.lock +++ b/uv.lock @@ -239,6 +239,7 @@ version = "0.4.0" source = { editable = "." } dependencies = [ { name = "click" }, + { name = "dukpy" }, { name = "lupa" }, { name = "pyyaml" }, { name = "tomli" }, @@ -264,6 +265,7 @@ scripts = [ [package.metadata] requires-dist = [ { name = "click" }, + { name = "dukpy", specifier = ">=0.3" }, { name = "lupa", specifier = ">=2.0" }, { name = "pyyaml", specifier = ">=6.0" }, { name = "tomli", specifier = ">=2.0" }, @@ -463,6 +465,60 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl", hash = "sha256:d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de", size = 633196, upload-time = "2025-12-18T19:00:18.077Z" }, ] +[[package]] +name = "dukpy" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dd/fe/8cef39f269aed53e940c238bf9ceb3ca0f80d7f5be6df2c00a84d87ac5d8/dukpy-0.5.0.tar.gz", hash = "sha256:079fe2d65ac5e24df56806c6b4e1a26f92bb7f13dc764f4fb230a6746744c1ad", size = 2078406, upload-time = "2024-11-07T21:50:59.148Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/72/c4/57fd8d24d78cfdcfb4ae14fcaaeee66704d84c510cb44348db899d57ed31/dukpy-0.5.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b39e6b33585a0018198eac09e5a9096de57cc3d9634b5c1346f9f9546f1f320c", size = 1380237, upload-time = "2024-11-07T21:45:31.018Z" }, + { url = "https://files.pythonhosted.org/packages/13/ff/a3b93d7692a8da2d094d46f5d9e0e3ad011c82c934c3598ee0132fd926bb/dukpy-0.5.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1cef2c26e8ae07d72955e3a965bc593a7392bec04a386ca3bdd7a01d6380b9e1", size = 1351458, upload-time = "2024-11-07T21:45:34.688Z" }, + { url = "https://files.pythonhosted.org/packages/df/eb/a641e9b1ff44a91cc72b3379c099187663aff42f709a036dbd2c927446e5/dukpy-0.5.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:86433be73f4a639e1c86caafe04d7bb528fb6ead2ec6d085e08f30127d89abe9", size = 2632499, upload-time = "2024-11-07T21:45:37.609Z" }, + { url = "https://files.pythonhosted.org/packages/44/de/b4904298ef703794b242496b8449de9347fec0f9e86f1e74b0a2e3011854/dukpy-0.5.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:489622e0e238db724705184c53ef68ba3f805f50535af6003ce01f19c78a94c1", size = 2693618, upload-time = "2024-11-07T21:45:40.73Z" }, + { url = "https://files.pythonhosted.org/packages/84/92/8c2bf000f4caf5121b1703cb9e8b6ca6f9764cba9c52ef2ce8d9c1a00693/dukpy-0.5.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:65ac3092714103b6bafa4ccf566bf80b1df37ff9e10cecdee240f7af86218384", size = 2631803, upload-time = "2024-11-07T21:45:44.065Z" }, + { url = "https://files.pythonhosted.org/packages/a2/54/3803e0916f6d046cc83495778ab0374a3838980edaaf726ded57c01d09a7/dukpy-0.5.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:9242006980fb122f781f093a853316a95cb99992831d50ee63ed24e1bd7b0421", size = 2610326, upload-time = "2024-11-07T21:45:48.551Z" }, + { url = "https://files.pythonhosted.org/packages/30/f7/5780c6178b4cedb1f3bcdc9c88ea3aab554e29c2d1011edf522585c77d93/dukpy-0.5.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:a3f8dc8b596623e504af82f73e4d3972c554d5e87c33704684a5c4bf4e443e0e", size = 2671312, upload-time = "2024-11-07T21:45:52.099Z" }, + { url = "https://files.pythonhosted.org/packages/46/7a/ee5804e077e52bc2889c3d3dc8f17adb072ab646af18068b8e0f59c133d3/dukpy-0.5.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:dbe8386cb659c6a07b2a8b2545c7df915902eb76fdcfe1bb528995b57f0f18b0", size = 2694802, upload-time = "2024-11-07T21:45:55.463Z" }, + { url = "https://files.pythonhosted.org/packages/87/85/5e6c152fdd9fc6a7131a7677e83e6965628a953f6e436e6f98cc769e5807/dukpy-0.5.0-cp310-cp310-win32.whl", hash = "sha256:4c3a0456c71005a898cd71cab7d5244eb2c7ef51a0918fbb9e6c3b78a1959410", size = 1266959, upload-time = "2024-11-07T21:45:57.473Z" }, + { url = "https://files.pythonhosted.org/packages/61/a8/5cabb4e6259553c385eab0f07f9e17f9c2a261a0e49585b701b720122152/dukpy-0.5.0-cp310-cp310-win_amd64.whl", hash = "sha256:3c5930a87d37f7152b77b797ee82468a108c0641aea4a054037d2c4fb5578be8", size = 1300533, upload-time = "2024-11-07T21:45:59.695Z" }, + { url = "https://files.pythonhosted.org/packages/6f/b2/4aa480da7328a2eddcc9fc34f6ef9b3bc3515a4bca58af49fd3aff9e628f/dukpy-0.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1864da59bf695f78a39ef99e292654799c0aa65217c1e6cc1df0957594831a82", size = 1380239, upload-time = "2024-11-07T21:46:02.572Z" }, + { url = "https://files.pythonhosted.org/packages/22/d4/d4917a134e2c76f569631718a8be15ef8ea34073455aff78eec270ed5124/dukpy-0.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e41356b605e2c4587eac57517866383281d4c31126daf05940c303ad1f6a6082", size = 1351463, upload-time = "2024-11-07T21:46:04.42Z" }, + { url = "https://files.pythonhosted.org/packages/c2/83/b8c121e877e4b2df5ef8c88936bd2a9bdab9800263a72083f0149772469b/dukpy-0.5.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54322332cc91b1579926942faa18be884f734b74403ae90c505c4d08f46db36d", size = 2632672, upload-time = "2024-11-07T21:46:07.437Z" }, + { url = "https://files.pythonhosted.org/packages/45/72/f125792abc52e7fd49918ca06ee070970a88cef25897c10108ca9e343634/dukpy-0.5.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7eff01efa8e8c0de13400e259e65669d180f1265dc8289743d85edc72423970a", size = 2693504, upload-time = "2024-11-07T21:46:09.997Z" }, + { url = "https://files.pythonhosted.org/packages/bf/b2/da915bd237ec006a568d9f1f5789f83318c086682dde83f11e3f0870056e/dukpy-0.5.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ef5238732a3565401d2a0c2a5b563ce6ff4ec65544627aa7711198e37e32f215", size = 2631776, upload-time = "2024-11-07T21:46:12.977Z" }, + { url = "https://files.pythonhosted.org/packages/1f/68/a84a8d3262da4d752972cf6697ca5a757ced9b51b7997e7493845a63cb1e/dukpy-0.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:db35091d7de72854a7f5f5a5c3f3084a4322693b9d04478adc4742d797ae84c2", size = 2610396, upload-time = "2024-11-07T21:46:16.323Z" }, + { url = "https://files.pythonhosted.org/packages/1b/71/1eeb16558caf5344f41926829b47f46afbf1a902a2e9be01714eca56054e/dukpy-0.5.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5d654004c01060798e86687d1b57ab1bccec8c9401090e9d1b61381baeebe425", size = 2671637, upload-time = "2024-11-07T21:46:19.915Z" }, + { url = "https://files.pythonhosted.org/packages/ff/78/bceb41afffcb584d425162ac1d5a4f4e6769b3c78a659f534e9fbdaf7bd0/dukpy-0.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:65c585afd71fd1ec005a0a00c4d2ee2ca1b418ea08a21ed97513ac492dc124d8", size = 2695072, upload-time = "2024-11-07T21:46:22.359Z" }, + { url = "https://files.pythonhosted.org/packages/85/fa/d83be4154b93c7ea96e8d6fce3862136f85083a965c7c5f5ce40b59d29c0/dukpy-0.5.0-cp311-cp311-win32.whl", hash = "sha256:8e2c5a99f2f174e745e8a65cd036e46c15df7f1d33f4faf9028ec906fba04132", size = 1266973, upload-time = "2024-11-07T21:46:25.144Z" }, + { url = "https://files.pythonhosted.org/packages/d9/1c/f8cdf71b3f9a89a1423a2cbb969d0b835e453ab3b3c7922528b685e1526a/dukpy-0.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:c70fc7dda4da92781411642e1e8a691136eeff4274dc2fea3b71b03199490ed4", size = 1300545, upload-time = "2024-11-07T21:46:27.995Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ed/38d48b10ce2784edd29182e005ad250bf8931a342c1567f48687fd1c6742/dukpy-0.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:609c68aa9060fbda5f3149ba9ade413af98f313a0b9e36e2e0bc62d31114a075", size = 1379187, upload-time = "2024-11-07T21:46:30.768Z" }, + { url = "https://files.pythonhosted.org/packages/f9/38/e683e72c1683764a619c21c81ca986195c3cb5d652e659a3e2b19a0a55f1/dukpy-0.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1c3911793615536ebea8201091e73666912b65e6af5335a8045cc88fa389a9b0", size = 1351506, upload-time = "2024-11-07T21:46:33.551Z" }, + { url = "https://files.pythonhosted.org/packages/6a/ba/cc1b6f3ba1c08d321dcee79b669d2840ef82fdf17634b15555103f1bd67a/dukpy-0.5.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:95bd4ae69f73ecf78a83c464cf998f2d7ee49be6ed70914300a47d5adbd29b63", size = 2633133, upload-time = "2024-11-07T21:46:37.301Z" }, + { url = "https://files.pythonhosted.org/packages/b4/34/aa0eff13b58ef11ca167197c6f9de1971b5b4bc57513afbdf261c548464d/dukpy-0.5.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e64b8f14e470d4badff11727c1b7b40241f0f98bc23c78e2232a78c20343b14", size = 2695294, upload-time = "2024-11-07T21:46:41.068Z" }, + { url = "https://files.pythonhosted.org/packages/b9/92/805e5927f6aabf3e59dc29b2aa8725136bfae6019b70318706ce73c3b858/dukpy-0.5.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3fdcb38ca2cb751f2741c054a2a0abee54b8bd1bf24ed1beab325074feb776d0", size = 2624872, upload-time = "2024-11-07T21:46:44.259Z" }, + { url = "https://files.pythonhosted.org/packages/8c/00/929d5a0d5d9612c17dfb6f67f93972fe78e6b696d1aaa69dec0dc842f50a/dukpy-0.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:11f1beb699cb82b8a8f9b901d61b6a52507f2a2a4a8d00cc4dab4b679b4ee9ca", size = 2609076, upload-time = "2024-11-07T21:46:47.176Z" }, + { url = "https://files.pythonhosted.org/packages/e7/fd/f7dcb07b264a6c738c0d6f96aec95092eb7ad17f7595576b37fc718666c3/dukpy-0.5.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d8db519073dc6c044bcafe5e963a9e89224a908a821c11c253c078c9ed490580", size = 2672588, upload-time = "2024-11-07T21:46:51.301Z" }, + { url = "https://files.pythonhosted.org/packages/88/ba/6f316ad3e770b2eb1283b0188a75ac201e16cf558452a4e7a51107e77c38/dukpy-0.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d331bf159bdd65fe839ecb60d1c0da917e53e1d93198d9ce31b3557f4371dc02", size = 2696016, upload-time = "2024-11-07T21:46:54.589Z" }, + { url = "https://files.pythonhosted.org/packages/1b/b3/050203cdf2b4711e7430b253db9da990923b3dc5075d00e4b5b42b979e98/dukpy-0.5.0-cp312-cp312-win32.whl", hash = "sha256:813c53b7561a075a6905541cdbeff00fb92dd2a57072623a2dcf08ea12af8c68", size = 1267003, upload-time = "2024-11-07T21:46:57.643Z" }, + { url = "https://files.pythonhosted.org/packages/7d/4e/98207a1dfa47e0adf5b4680eeabd65ecb020a9ed1ff2078981c88fb668c2/dukpy-0.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:336354b27cca64d0256932344167bd45c43a9bf0a3bc30eaae8c237c46a862fb", size = 1300602, upload-time = "2024-11-07T21:47:00.408Z" }, + { url = "https://files.pythonhosted.org/packages/04/e1/9bc2bc0d5b2d7c0b579ae9161e0ba7a2049715dbef2b997f36ca9dfbd861/dukpy-0.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5b8b28e235af8c43670b5b4ea7f2f44d3348f5f84a7b582d4016114c3a44b04d", size = 1379175, upload-time = "2024-11-07T21:47:03.034Z" }, + { url = "https://files.pythonhosted.org/packages/ed/19/ef6c32d789317b8055a36ff12fc6df8b17155c02f7521f5debd8b475bfbd/dukpy-0.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:434eb3b012a5ff5ecf3990154740da186477ecaa832da2d0a9063f0cb1080fa7", size = 1351491, upload-time = "2024-11-07T21:47:06.817Z" }, + { url = "https://files.pythonhosted.org/packages/5d/5d/2415c0889603fac62f699e459fbc28ba9f569aedcc16f308345b217b6aaf/dukpy-0.5.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:342ee205ba86a0d7952aaa788793bd045603f5340b9469bb5b23d14247205b65", size = 2633165, upload-time = "2024-11-07T21:47:10.84Z" }, + { url = "https://files.pythonhosted.org/packages/f7/4f/7ccdd6cd262b5d39137821e83b410906a6b8868a2028a08bdf7e9d6a7609/dukpy-0.5.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3794581846f16e574551b0277539e3bef5254a1e1e0749d6023697634817032a", size = 2695433, upload-time = "2024-11-07T21:47:14.892Z" }, + { url = "https://files.pythonhosted.org/packages/ab/d6/63abe4aae4519adc91051a59a0626f2fe9ced881f8b6203abd17ebd51bf0/dukpy-0.5.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1f3528103ab1e3ef681fa76c2bcb99424f87d4032470bd1b4ca3534392c831e5", size = 2624969, upload-time = "2024-11-07T21:47:18.848Z" }, + { url = "https://files.pythonhosted.org/packages/be/3f/cca42e927ffd37ed807593effa48aa50ed8f902f19e47d45520a93b70a87/dukpy-0.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f31eab72785a36916aaf0d5ac068d4d7f51be12aee31a67a85db98ddc9c77efa", size = 2609135, upload-time = "2024-11-07T21:47:21.84Z" }, + { url = "https://files.pythonhosted.org/packages/ae/c9/6c3a65121bbb66ee42a53dceea3fff337a413f6710a78931742f5000aeac/dukpy-0.5.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7c677a7dc784190dae985c032d01c33474bdd299c8c66cd6922693f6a54cc8da", size = 2672610, upload-time = "2024-11-07T21:47:25.125Z" }, + { url = "https://files.pythonhosted.org/packages/79/d2/534ba58665fae7fe8c09b184364c22ac1d0cc8ef7df110e7aade8231fadf/dukpy-0.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f17de94654f94b34d223a961585d99ae990fc3ee7812c0db57b07cfcaae3bb94", size = 2696015, upload-time = "2024-11-07T21:47:29.61Z" }, + { url = "https://files.pythonhosted.org/packages/dc/e7/f0b2828f6ababc4ae03fc32e4b285662f7aa7b5f3df7999c80fa46319e4e/dukpy-0.5.0-cp313-cp313-win32.whl", hash = "sha256:b31c89df9fa1c90205a023adc124c24ba20b33b1138138b2d7fcbbb2e48bdcab", size = 1266997, upload-time = "2024-11-07T21:47:31.936Z" }, + { url = "https://files.pythonhosted.org/packages/8c/2d/735c4be716e1aec7e2a515d9f80530f3a3dc5ec1531cdedc8444504c4b33/dukpy-0.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:48a56ea9e41bdb15edea5a4a20f902f1726e4dae216f39a8ea1d5301f7ba2346", size = 1300597, upload-time = "2024-11-07T21:47:34.516Z" }, + { url = "https://files.pythonhosted.org/packages/82/bc/db2a6be7606ebab184ce4c014e7d61735fdddabb94dca22a4c16fdb9bc5d/dukpy-0.5.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:5f8ac865eeff43e17170b9e4677990f21d57ec8b0a4a3fbc8497c1f8e46fb276", size = 1339933, upload-time = "2024-11-07T21:49:49.895Z" }, + { url = "https://files.pythonhosted.org/packages/06/44/806d53ba269aea6c337e334395bd05907ec0e17445fa27ef2424a3fb4d95/dukpy-0.5.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:96a8e51dcdcfd4dfa5b3895927c337fbdaff53f342d6079840378798d9b51b5e", size = 1314838, upload-time = "2024-11-07T21:49:52.557Z" }, + { url = "https://files.pythonhosted.org/packages/28/16/d93a2c4919d07be0a82c22c8a9a20608c755915cc1dc2cf1dd7cb7828ec6/dukpy-0.5.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b26594c9cf5b99a9949c4d92c7012bb5640fe53540f0efd0b6f73e503f535bdb", size = 1301042, upload-time = "2024-11-07T21:49:56.031Z" }, + { url = "https://files.pythonhosted.org/packages/7c/c9/04bed8d655e91ea39b3b4901b8ca46ffab5a1fbf2005e2ecde11f1b3248d/dukpy-0.5.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:821352df62419edfadc8fe80de01737a04b19e7ce415e4c22fba639d2622b543", size = 1314456, upload-time = "2024-11-07T21:49:59.002Z" }, + { url = "https://files.pythonhosted.org/packages/3a/cb/19326f0ea6758238e7c8892cfbc0a38fc32aadc9a41bb1ca95acec529e48/dukpy-0.5.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07394788daa55019aaf7b34cfa5bcb4f9eaac1774360d4e2347eba46ba0b22c8", size = 1336925, upload-time = "2024-11-07T21:50:03.151Z" }, + { url = "https://files.pythonhosted.org/packages/b5/be/3821a846f939aaea9a123aeac93dbcc07e411c4aa5b96cf14ed681f225bc/dukpy-0.5.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:a69b0e12a6326a9de1020deacd902b822e5abd58f4b7a17007ed442e76be337b", size = 1300581, upload-time = "2024-11-07T21:50:06.622Z" }, +] + [[package]] name = "exceptiongroup" version = "1.3.1" From 6eaf132ecbd970a876d764823f97394d9ab0bab3 Mon Sep 17 00:00:00 2001 From: zetlen Date: Mon, 19 Jan 2026 22:16:48 -0600 Subject: [PATCH 3/7] chore: add schema update checks to pre-commit and release workflow - Add schema-check job to lefthook pre-commit that verifies schemas are up-to-date when modifying schema.py or serializers.py - Add schema generation step to release-please workflow to ensure schemas are always updated in release PRs - Fix potential injection vulnerability by using env: for PR_JSON Co-Authored-By: Claude Opus 4.5 --- .github/workflows/release-please.yml | 11 +++++++---- lefthook.yml | 12 ++++++++++++ 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 9334e85..699c9c0 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -40,8 +40,9 @@ jobs: steps: - name: Get PR info id: pr-info + env: + PR_JSON: ${{ needs.release-please.outputs.pr }} run: | - PR_JSON='${{ needs.release-please.outputs.pr }}' echo "branch=$(echo "$PR_JSON" | jq -r '.headBranchName')" >> $GITHUB_OUTPUT - uses: actions/checkout@v5 with: @@ -51,15 +52,17 @@ jobs: uses: astral-sh/setup-uv@v5 - name: Update uv.lock run: uv sync + - name: Update JSON schemas + run: uv run python scripts/generate_schemas.py - name: Commit and push if changed run: | git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" - git add uv.lock + git add uv.lock ctec.schema.json iterm2-color-scheme.schema.json if git diff --staged --quiet; then - echo "No changes to uv.lock" + echo "No changes to commit" else - git commit -m "chore: update uv.lock for release" + git commit -m "chore: update uv.lock and schemas for release" git push fi diff --git a/lefthook.yml b/lefthook.yml index af934be..74d1f1a 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -30,3 +30,15 @@ pre-commit: glob: - "pyproject.toml" - "uv.lock" + + - name: schema-check + run: | + uv run python scripts/generate_schemas.py + if ! git diff --quiet ctec.schema.json iterm2-color-scheme.schema.json; then + echo "Schema files are out of date! Run 'uv run python scripts/generate_schemas.py' and commit the changes." + git diff ctec.schema.json iterm2-color-scheme.schema.json + exit 1 + fi + glob: + - "console_cowboy/ctec/schema.py" + - "console_cowboy/ctec/serializers.py" From 015e3b1db33ab27d4ac767cc7915ecdd278ae952 Mon Sep 17 00:00:00 2001 From: zetlen Date: Mon, 19 Jan 2026 22:19:18 -0600 Subject: [PATCH 4/7] chore: add mise tasks for schemas and knowledge base Add new mise tasks for local development: - `schemas`: Generate JSON schemas for CTEC format - `knowledge-base`: Fetch terminal documentation (requires network) - `setup`: Full local dev setup (sync + schemas) The knowledge-base task is intentionally separate from setup since it requires network access and takes time. Developers can optionally run it when needed. Co-Authored-By: Claude Opus 4.5 --- mise.toml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/mise.toml b/mise.toml index bd0fd02..2fceb5a 100644 --- a/mise.toml +++ b/mise.toml @@ -18,6 +18,26 @@ uv sync uv run lefthook install """ +[tasks.schemas] +depends = ["sync"] +description = "Generate JSON schemas for CTEC format" +run = "uv run python scripts/generate_schemas.py" + +[tasks."knowledge-base"] +description = "Fetch terminal documentation for knowledge base (skipped in CI)" +run = """ +if [ -n "$CI" ]; then + echo "Skipping knowledge base fetch in CI environment" + exit 0 +fi +uv sync --group scripts && uv run python scripts/build_knowledge_base.py +""" + +[tasks.setup] +depends = ["sync", "schemas", "knowledge-base"] +description = "Full local dev setup (dependencies, hooks, schemas, docs)" +run = "echo 'Development environment ready!'" + [tasks.test] depends = ["sync"] description = "Run tests with pytest" From 2207e5ca4d9165c65c7415920dc287ebe95801c1 Mon Sep 17 00:00:00 2001 From: zetlen Date: Mon, 19 Jan 2026 22:25:48 -0600 Subject: [PATCH 5/7] ci: skip coverage comment on main branch pushes Only post coverage comments on pull requests, not on pushes to main. Co-Authored-By: Claude Opus 4.5 --- .github/workflows/test.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 25c7793..ecee2b5 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -58,6 +58,7 @@ jobs: - name: Run tests with coverage run: mise run test - name: Coverage comment + if: github.event_name == 'pull_request' uses: py-cov-action/python-coverage-comment-action@v3 with: GITHUB_TOKEN: ${{ github.token }} From e85a1d9bfb9c4a7a3b83d303bc4d10941d529c62 Mon Sep 17 00:00:00 2001 From: zetlen Date: Mon, 19 Jan 2026 22:27:52 -0600 Subject: [PATCH 6/7] chore: add knowledge base fetchers for Hyper and VSCode Add documentation fetchers to cover all terminal adapters: - Hyper: README and API docs from GitHub - VSCode: Terminal docs from microsoft/vscode-docs Co-Authored-By: Claude Opus 4.5 --- scripts/build_knowledge_base.py | 42 +++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/scripts/build_knowledge_base.py b/scripts/build_knowledge_base.py index de0156d..61fe4c0 100644 --- a/scripts/build_knowledge_base.py +++ b/scripts/build_knowledge_base.py @@ -527,6 +527,48 @@ def get_fetchers(): ), ], ), + "hyper": CombinedDocFetcher( + "hyper", + "Hyper terminal configuration", + [ + ( + "Configuration", + "https://raw.githubusercontent.com/vercel/hyper/canary/README.md", + "raw", + ), + ( + "API", + "https://raw.githubusercontent.com/vercel/hyper/canary/docs/api.md", + "raw", + ), + ], + ), + "vscode": CombinedDocFetcher( + "vscode", + "VS Code integrated terminal settings", + [ + ( + "Terminal Basics", + "https://raw.githubusercontent.com/microsoft/vscode-docs/main/docs/terminal/basics.md", + "raw", + ), + ( + "Terminal Appearance", + "https://raw.githubusercontent.com/microsoft/vscode-docs/main/docs/terminal/appearance.md", + "raw", + ), + ( + "Terminal Profiles", + "https://raw.githubusercontent.com/microsoft/vscode-docs/main/docs/terminal/profiles.md", + "raw", + ), + ( + "Terminal Advanced", + "https://raw.githubusercontent.com/microsoft/vscode-docs/main/docs/terminal/advanced.md", + "raw", + ), + ], + ), } # macOS-only fetcher From 32ffec6fcf83c6e9bc43a76cf458c41eeef48d39 Mon Sep 17 00:00:00 2001 From: zetlen Date: Mon, 19 Jan 2026 22:34:04 -0600 Subject: [PATCH 7/7] fix(hyper): address code review issues - Remove dead code: incorrect isinstance check in _export_terminal_specific - Fix string interpolation bug: use placeholder instead of % operator to avoid TypeError when config contains printf-style format specifiers - Add test for configs containing % characters Co-Authored-By: Claude Opus 4.5 --- console_cowboy/terminals/hyper/adapter.py | 12 ++++-------- console_cowboy/terminals/hyper/javascript.py | 15 +++++++++------ tests/test_hyper.py | 17 +++++++++++++++++ 3 files changed, 30 insertions(+), 14 deletions(-) diff --git a/console_cowboy/terminals/hyper/adapter.py b/console_cowboy/terminals/hyper/adapter.py index f52e625..c25248d 100644 --- a/console_cowboy/terminals/hyper/adapter.py +++ b/console_cowboy/terminals/hyper/adapter.py @@ -611,15 +611,11 @@ def _export_scroll(cls, scroll: ScrollConfig, items: list[str]) -> None: def _export_terminal_specific(cls, ctec: CTEC, items: list[str]) -> None: """Export terminal-specific settings back to Hyper.""" for setting in ctec.get_terminal_specific("hyper"): - if not isinstance(setting, list): # Skip already processed ones - key = setting.key - value = setting.value - - # Skip plugins and localPlugins (handled separately) - if key in ("plugins", "localPlugins"): - continue + # Skip plugins and localPlugins (handled separately) + if setting.key in ("plugins", "localPlugins"): + continue - items.append(f"{key}: {cls._format_js_value(value)}") + items.append(f"{setting.key}: {cls._format_js_value(setting.value)}") @classmethod def _export_keybindings(cls, bindings: list[KeyBinding]) -> str: diff --git a/console_cowboy/terminals/hyper/javascript.py b/console_cowboy/terminals/hyper/javascript.py index 2b5b295..1af14dc 100644 --- a/console_cowboy/terminals/hyper/javascript.py +++ b/console_cowboy/terminals/hyper/javascript.py @@ -29,20 +29,23 @@ def execute_hyper_config(js_source: str) -> dict[str, Any]: # Wrap the user's code to capture module.exports # We create a mock module object and execute the user's code, # then return the exports - wrapper = """ - (function() { - var module = { exports: {} }; + # Note: Using a unique placeholder instead of %s to avoid issues with + # user code containing printf-style format specifiers like %s, %d, etc. + placeholder = "___HYPER_CONFIG_SOURCE___" + wrapper = f""" + (function() {{ + var module = {{ exports: {{}} }}; var exports = module.exports; // User's code goes here - %s + {placeholder} return module.exports; - })() + }})() """ try: - result = dukpy.evaljs(wrapper % js_source) + result = dukpy.evaljs(wrapper.replace(placeholder, js_source)) except Exception as e: raise ValueError(f"Failed to execute Hyper config: {e}") from e diff --git a/tests/test_hyper.py b/tests/test_hyper.py index 2d39b18..90a68fa 100644 --- a/tests/test_hyper.py +++ b/tests/test_hyper.py @@ -489,3 +489,20 @@ def test_missing_exports_raises_error(self): with pytest.raises(ValueError, match="did not export"): execute_hyper_config("var x = 1;") + + def test_config_with_percent_characters(self): + """Test that config containing % characters doesn't cause format string errors.""" + from console_cowboy.terminals.hyper.javascript import execute_hyper_config + + # This would fail with TypeError if using % string interpolation + js_source = """ +module.exports = { + config: { + shell: '/bin/bash', + shellArgs: ['-c', 'printf "%s" test'] + } +}; +""" + result = execute_hyper_config(js_source) + assert result["config"]["shell"] == "/bin/bash" + assert result["config"]["shellArgs"] == ["-c", 'printf "%s" test']