diff --git a/CHANGELOG.md b/CHANGELOG.md index b5bdd29..bdf1f12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [Unreleased] + +### Added +- Accept aliases like `nerd-fonts` when setting the statusline icon style through `sessions config features`. This makes the CLI friendlier by matching the terminology used in docs and installer prompts. + +### Fixed +- Toggling `icon_style` now works even if legacy configs store the value as a plain string or boolean, ensuring seamless upgrades without manual file edits. + ## [0.3.6] - 2025-10-17 ### Fixed diff --git a/cc_sessions/javascript/statusline.js b/cc_sessions/javascript/statusline.js index 55606cc..a75099a 100644 --- a/cc_sessions/javascript/statusline.js +++ b/cc_sessions/javascript/statusline.js @@ -464,6 +464,37 @@ function main() { line2Parts.push(gitBranchInfo); } console.log(line2Parts.join(' | ')); + + // Line 3 - Trigger phrase cheat sheet + const triggerConfig = config?.trigger_phrases || {}; + const defaultTriggers = { + implementation_mode: ['yert'], + discussion_mode: ['SILENCE'], + task_creation: ['mek:'], + task_startup: ['start^'], + task_completion: ['finito'], + context_compaction: ['squish'] + }; + + const triggerGroups = [ + { key: 'implementation_mode', label: 'Go', color: green }, + { key: 'discussion_mode', label: 'No', color: purple }, + { key: 'task_creation', label: 'Create', color: cyan }, + { key: 'task_startup', label: 'Start', color: orange }, + { key: 'task_completion', label: 'Complete', color: red }, + { key: 'context_compaction', label: 'Compact', color: lGray } + ]; + + const formattedTriggers = triggerGroups.map(({ key, label, color }) => { + const phrases = Array.isArray(triggerConfig[key]) + ? triggerConfig[key] + : defaultTriggers[key]; + const phraseText = phrases && phrases.length ? phrases.join(', ') : '—'; + return `${color}${label}:${reset} ${lGray}${phraseText}${reset}`; + }); + + const triggerLabel = `${purple}Triggers:${reset}`; + console.log(`${triggerLabel} ${formattedTriggers.join(` ${gray}|${reset} `)}`); } if (require.main === module) { diff --git a/cc_sessions/python/api/config_commands.py b/cc_sessions/python/api/config_commands.py index b7d5cd8..ef65862 100644 --- a/cc_sessions/python/api/config_commands.py +++ b/cc_sessions/python/api/config_commands.py @@ -11,7 +11,18 @@ ##-## ## ===== LOCAL ===== ## -from hooks.shared_state import load_config, edit_config, TriggerCategory, GitAddPattern, GitCommitStyle, UserOS, UserShell, CCTools, IconStyle +from hooks.shared_state import ( + load_config, + edit_config, + TriggerCategory, + GitAddPattern, + GitCommitStyle, + UserOS, + UserShell, + CCTools, + IconStyle, + EnabledFeatures, +) ##-## #-# @@ -612,12 +623,14 @@ def get_value(field): return field.value if hasattr(field, 'value') else field final_value = bool_value elif key == 'icon_style': - # Enum feature - accepts nerd_fonts, emoji, ascii - try: - config.features.icon_style = IconStyle(value.lower()) - final_value = value.lower() - except ValueError: - raise ValueError(f"Invalid icon_style value: {value}. Valid values: nerd_fonts, emoji, ascii") + # Enum feature - accepts nerd_fonts, emoji, ascii (+ aliases) + icon_style, _, recognized = EnabledFeatures.normalize_icon_style(value) + if not recognized: + raise ValueError( + f"Invalid icon_style value: {value}. Valid values: nerd_fonts, nerd-fonts, emoji, ascii" + ) + config.features.icon_style = icon_style + final_value = icon_style.value elif key in ['warn_85', 'warn_90']: # Context warning features @@ -652,8 +665,11 @@ def get_value(field): return field.value if hasattr(field, 'value') else field # Toggle/cycle the value if key == 'icon_style': # Cycle through enum values: nerd_fonts -> emoji -> ascii -> nerd_fonts + current_style, _, recognized = EnabledFeatures.normalize_icon_style(current_value) + if not recognized: + current_style = IconStyle.NERD_FONTS cycle = [IconStyle.NERD_FONTS, IconStyle.EMOJI, IconStyle.ASCII] - current_idx = cycle.index(current_value) + current_idx = cycle.index(current_style) new_value = cycle[(current_idx + 1) % len(cycle)] else: # Boolean toggle @@ -669,8 +685,12 @@ def get_value(field): return field.value if hasattr(field, 'value') else field setattr(config.features.context_warnings, key, new_value) # Format values for display - old_display = current_value.value if hasattr(current_value, 'value') else current_value - new_display = new_value.value if hasattr(new_value, 'value') else new_value + if key == 'icon_style': + old_display = current_style.value + new_display = new_value.value + else: + old_display = current_value + new_display = new_value if json_output: return {"toggled": key, "old_value": old_display, "new_value": new_display} @@ -694,7 +714,7 @@ def format_features_help() -> str: " branch_enforcement - Git branch validation (default: true)", " task_detection - Task-based workflow automation (default: true)", " auto_ultrathink - Enhanced AI reasoning (default: true)", - " icon_style - Statusline icon style: nerd_fonts, emoji, or ascii (default: nerd_fonts)", + " icon_style - Statusline icon style: nerd_fonts (aka nerd-fonts), emoji, or ascii (default: nerd_fonts)", " warn_85 - Context warning at 85% (default: true)", " warn_90 - Context warning at 90% (default: true)", "", diff --git a/cc_sessions/python/hooks/shared_state.py b/cc_sessions/python/hooks/shared_state.py index f95294a..948ea6b 100755 --- a/cc_sessions/python/hooks/shared_state.py +++ b/cc_sessions/python/hooks/shared_state.py @@ -5,7 +5,7 @@ ## ===== STDLIB ===== ## from __future__ import annotations -from typing import Optional, List, Dict, Any, Iterator, Literal, Union +from typing import Optional, List, Dict, Any, Iterator, Literal, Union, ClassVar from importlib.metadata import version, PackageNotFoundError from dataclasses import dataclass, asdict, field from contextlib import contextmanager, suppress @@ -308,29 +308,68 @@ class EnabledFeatures: icon_style: IconStyle = IconStyle.NERD_FONTS context_warnings: ContextWarnings = field(default_factory=ContextWarnings) + # Common aliases users may type when configuring icon styles + ICON_STYLE_ALIASES: ClassVar[Dict[str, IconStyle]] = { + "nerdfonts": IconStyle.NERD_FONTS, + "nerd_fonts": IconStyle.NERD_FONTS, + "nerd-fonts": IconStyle.NERD_FONTS, + "default": IconStyle.NERD_FONTS, + "emoji": IconStyle.EMOJI, + "emojis": IconStyle.EMOJI, + "ascii": IconStyle.ASCII, + "plain": IconStyle.ASCII, + "text": IconStyle.ASCII, + } + + @classmethod + def normalize_icon_style(cls, value: Any) -> tuple[IconStyle, bool, bool]: + """Normalize icon style values. + + Returns (icon_style, changed, recognized). + """ + if isinstance(value, IconStyle): + return value, False, True + + if value is None: + return IconStyle.NERD_FONTS, False, True + + if isinstance(value, bool): + return (IconStyle.NERD_FONTS if value else IconStyle.ASCII), True, True + + if isinstance(value, str): + cleaned = value.strip() + if not cleaned: + return IconStyle.NERD_FONTS, True, False + + lowered = cleaned.lower() + normalized = lowered.replace("-", "_").replace(" ", "_") + if normalized in cls.ICON_STYLE_ALIASES: + icon = cls.ICON_STYLE_ALIASES[normalized] + return icon, icon.value != lowered, True + + try: + icon = IconStyle(normalized) + return icon, icon.value != lowered, True + except ValueError: + return IconStyle.NERD_FONTS, True, False + + return IconStyle.NERD_FONTS, True, False + @classmethod def from_dict(cls, d: Dict[str, Any]) -> "EnabledFeatures": cw_data = d.get("context_warnings", {}) if cw_data and isinstance(cw_data, dict): cw = ContextWarnings(**cw_data) else: cw = ContextWarnings() - # Handle migration from old use_nerd_fonts boolean to new icon_style enum - icon_style_value = d.get("icon_style") - if icon_style_value is None: - # Check for old boolean field - old_use_nerd_fonts = d.get("use_nerd_fonts") - if old_use_nerd_fonts is not None: - # Migrate: True -> NERD_FONTS, False -> ASCII - icon_style_value = IconStyle.NERD_FONTS if old_use_nerd_fonts else IconStyle.ASCII - else: - # No old or new field, use default - icon_style_value = IconStyle.NERD_FONTS - elif isinstance(icon_style_value, str): - # Convert string to enum - try: - icon_style_value = IconStyle(icon_style_value) - except ValueError: - icon_style_value = IconStyle.NERD_FONTS + icon_source = None + if "icon_style" in d: + icon_source = d["icon_style"] + elif "use_nerd_fonts" in d: + icon_source = d["use_nerd_fonts"] + + icon_style_value, _, recognized = cls.normalize_icon_style(icon_source) + if not recognized: + icon_style_value = IconStyle.NERD_FONTS return cls( branch_enforcement=d.get("branch_enforcement", True), @@ -867,8 +906,19 @@ def load_config() -> SessionsConfig: # Check if migration is needed from use_nerd_fonts to icon_style needs_migration = False - if "features" in data and "use_nerd_fonts" in data["features"] and "icon_style" not in data["features"]: - needs_migration = True + features_data = data.get("features", {}) if isinstance(data, dict) else {} + icon_source = None + if isinstance(features_data, dict): + if "icon_style" in features_data: + icon_source = features_data["icon_style"] + if "use_nerd_fonts" in features_data and "icon_style" not in features_data: + needs_migration = True + icon_source = features_data["use_nerd_fonts"] + + if icon_source is not None: + _, icon_changed, icon_recognized = EnabledFeatures.normalize_icon_style(icon_source) + if icon_changed or not icon_recognized: + needs_migration = True config = SessionsConfig.from_dict(data) diff --git a/tests/test_features_icon_style.py b/tests/test_features_icon_style.py new file mode 100644 index 0000000..526d652 --- /dev/null +++ b/tests/test_features_icon_style.py @@ -0,0 +1,84 @@ +import contextlib +import os +import sys +import tempfile +import types +from pathlib import Path + +# Ensure shared_state locates an isolated project root before modules import it +_TEST_ROOT = Path(tempfile.mkdtemp(prefix="cc-sessions-test-")) +os.environ.setdefault("CLAUDE_PROJECT_DIR", str(_TEST_ROOT)) + +# Ensure the project package can be imported without installation +PROJECT_ROOT = Path(__file__).resolve().parents[1] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +PYTHON_DIR = PROJECT_ROOT / "cc_sessions" / "python" +if str(PYTHON_DIR) not in sys.path: + sys.path.insert(0, str(PYTHON_DIR)) + +from hooks.shared_state import EnabledFeatures, IconStyle +from cc_sessions.python.api import config_commands + + +def _make_config(icon_style): + features = types.SimpleNamespace( + branch_enforcement=True, + task_detection=True, + auto_ultrathink=True, + icon_style=icon_style, + context_warnings=types.SimpleNamespace(warn_85=True, warn_90=True), + ) + return types.SimpleNamespace(features=features) + + +def test_normalize_icon_style_aliases(): + style, changed, recognized = EnabledFeatures.normalize_icon_style("nerd-fonts") + assert style is IconStyle.NERD_FONTS + assert changed is True + assert recognized is True + + style, changed, recognized = EnabledFeatures.normalize_icon_style("emoji") + assert style is IconStyle.EMOJI + assert changed is False + assert recognized is True + + style, changed, recognized = EnabledFeatures.normalize_icon_style(False) + assert style is IconStyle.ASCII + assert changed is True + assert recognized is True + + style, _, recognized = EnabledFeatures.normalize_icon_style("bogus") + assert style is IconStyle.NERD_FONTS + assert recognized is False + + +def test_handle_features_set_accepts_alias(monkeypatch): + config = _make_config(IconStyle.EMOJI) + + @contextlib.contextmanager + def fake_edit(): + yield config + + monkeypatch.setattr(config_commands, "edit_config", fake_edit) + + message = config_commands.handle_features_command(["set", "icon_style", "nerd-fonts"]) + assert config.features.icon_style is IconStyle.NERD_FONTS + assert "nerd_fonts" in message + + +def test_handle_features_toggle_handles_strings(monkeypatch): + config = _make_config("emoji") + + @contextlib.contextmanager + def fake_edit(): + yield config + + monkeypatch.setattr(config_commands, "edit_config", fake_edit) + monkeypatch.setattr(config_commands, "load_config", lambda: config) + + message = config_commands.handle_features_command(["toggle", "icon_style"]) + assert config.features.icon_style is IconStyle.ASCII + assert "emoji" in message.lower() + assert "ascii" in message.lower()