Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
31 changes: 31 additions & 0 deletions cc_sessions/javascript/statusline.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
42 changes: 31 additions & 11 deletions cc_sessions/python/api/config_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
##-##

#-#
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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}
Expand All @@ -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)",
"",
Expand Down
90 changes: 70 additions & 20 deletions cc_sessions/python/hooks/shared_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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)

Expand Down
84 changes: 84 additions & 0 deletions tests/test_features_icon_style.py
Original file line number Diff line number Diff line change
@@ -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()