"""Single source of truth for Ralph CLI display dependencies.
No renderer may construct its own Console. All display code must receive
a DisplayContext (or build one via make_display_context) that owns the
console, theme, terminal width, color policy, mode, and adaptive limits.
"""
from __future__ import annotations
import os
import signal
import sys
import threading
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Final, Literal
from ralph.display._mode_adaptive_limits import _ModeAdaptiveLimits
from ralph.display._resolved_env import _ResolvedEnv
from ralph.display.mode import MEDIUM_THRESHOLD, NARROW_THRESHOLD
from ralph.display.theme import (
ASCII_GLYPHS,
RALPH_THEME,
UNICODE_GLYPHS,
detect_glyph_capability,
make_console,
)
if TYPE_CHECKING:
from collections.abc import Callable, Mapping
from rich.console import Console
from rich.theme import Theme
COMPACT_HEADLINE_MAX_CHARS: Final[int] = 80
_MEDIUM_HEADLINE_MAX_CHARS: Final[int] = 100
WIDE_HEADLINE_MAX_CHARS: Final[int] = 120
COMPACT_CONDENSER_SOFT_LIMIT: Final[int] = 240
_MEDIUM_CONDENSER_SOFT_LIMIT: Final[int] = 300
WIDE_CONDENSER_SOFT_LIMIT: Final[int] = 400
COMPACT_CONDENSER_HARD_LIMIT: Final[int] = 2400
_MEDIUM_CONDENSER_HARD_LIMIT: Final[int] = 3200
WIDE_CONDENSER_HARD_LIMIT: Final[int] = 4000
COMPACT_STREAMING_CHECKPOINT_CHARS: Final[int] = 2400
_MEDIUM_STREAMING_CHECKPOINT_CHARS: Final[int] = 3200
WIDE_STREAMING_CHECKPOINT_CHARS: Final[int] = 4000
COMPACT_THINKING_PREVIEW_MIN_CHARS: Final[int] = 60
_MEDIUM_THINKING_PREVIEW_MIN_CHARS: Final[int] = 70
WIDE_THINKING_PREVIEW_MIN_CHARS: Final[int] = 80
COMPACT_TOOL_RESULT_HEADLINE_MIN_CHARS: Final[int] = 60
_MEDIUM_TOOL_RESULT_HEADLINE_MIN_CHARS: Final[int] = 70
WIDE_TOOL_RESULT_HEADLINE_MIN_CHARS: Final[int] = 80
_STREAMING_CHECKPOINT_FRAGMENTS: Final[int] = 20
_STREAMING_DEDUP_DISABLED_VALUES: frozenset[str] = frozenset({"0", "false", "no", "off"})
_STREAMING_CHECKPOINTS_DISABLED_VALUES: frozenset[str] = frozenset({"0", "false", "no", "off"})
_RALPH_FORCE_NARROW_TRUTHY: frozenset[str] = frozenset({"1", "true", "yes", "on"})
_RALPH_FORCE_ASCII_TRUTHY: frozenset[str] = frozenset({"1", "true", "yes", "on"})
def _resolve_env(env: Mapping[str, str]) -> _ResolvedEnv:
"""Parse environment variables into resolved display settings.
Args:
env: Environment mapping to parse.
Returns:
_ResolvedEnv with all display-relevant env settings resolved.
"""
no_color = "NO_COLOR" in env
force_color = "FORCE_COLOR" in env
force_narrow_val = env.get("RALPH_FORCE_NARROW", "").lower().strip()
force_narrow = force_narrow_val in _RALPH_FORCE_NARROW_TRUTHY
force_ascii_val = env.get("RALPH_FORCE_ASCII", "").lower().strip()
force_ascii = force_ascii_val in _RALPH_FORCE_ASCII_TRUTHY
columns: int | None = None
if "COLUMNS" in env:
try:
w = int(env["COLUMNS"])
columns = w if w > 0 else None
except (ValueError, TypeError):
pass
streaming_dedup_val = env.get("RALPH_STREAMING_DEDUP", "").lower().strip()
streaming_dedup_enabled = streaming_dedup_val not in _STREAMING_DEDUP_DISABLED_VALUES
streaming_checkpoints_val = env.get("RALPH_STREAMING_CHECKPOINTS", "").lower().strip()
streaming_checkpoints_enabled = (
streaming_checkpoints_val not in _STREAMING_CHECKPOINTS_DISABLED_VALUES
)
return _ResolvedEnv(
no_color=no_color,
force_color=force_color,
force_narrow=force_narrow,
columns=columns,
force_ascii=force_ascii,
streaming_dedup_enabled=streaming_dedup_enabled,
streaming_checkpoints_enabled=streaming_checkpoints_enabled,
)
def _console_has_no_color(console: Console) -> bool:
"""Return True when the console has color disabled via its no_color attribute."""
raw: object = getattr(console, "no_color", False)
return bool(raw)
def _build_console(resolved_env: _ResolvedEnv) -> Console:
"""Create a console based on resolved NO_COLOR / FORCE_COLOR settings.
Args:
resolved_env: Pre-resolved environment settings.
Returns:
Configured Console instance.
"""
if resolved_env.no_color:
return make_console(no_color=True, force_terminal=False)
if resolved_env.force_color:
return make_console(no_color=False, force_terminal=True)
return make_console()
def _compute_width(
resolved_env: _ResolvedEnv,
console: Console,
force_width: int | None,
) -> int:
"""Resolve effective terminal width from overrides, env, and console.
Args:
resolved_env: Pre-resolved environment settings.
console: Console to read width from as fallback.
force_width: Explicit width override.
Returns:
Effective terminal width in characters.
"""
if force_width is not None and force_width > 0:
return force_width
if resolved_env.columns is not None:
return resolved_env.columns
return console.width or 80
def _compute_mode(
resolved_env: _ResolvedEnv,
force_mode: Literal["compact", "medium", "wide"] | None,
width: int,
) -> Literal["compact", "medium", "wide"]:
"""Resolve display mode from overrides, env flags, and terminal width.
Args:
resolved_env: Pre-resolved environment settings.
force_mode: Explicit mode override.
width: Effective terminal width.
Returns:
Resolved display mode.
"""
if force_mode is not None:
return force_mode
if resolved_env.force_narrow or width < NARROW_THRESHOLD:
return "compact"
if width < MEDIUM_THRESHOLD:
return "medium"
return "wide"
[docs]
@dataclass(frozen=True)
class DisplayContext:
"""Immutable container for all display configuration and dependencies.
This is the single source of truth for display behavior. No renderer
may construct its own Console. Obtain one via make_display_context().
Attributes:
console: Rich Console instance for all rendering.
theme: Rich Theme with Ralph's Okabe-Ito color palette.
width: Effective terminal width in characters.
mode: Display mode - 'compact', 'medium', or 'wide'.
narrow: True when mode is 'compact'.
color_enabled: True when color output is enabled.
glyphs_enabled: True when Unicode glyphs should be used, False for ASCII fallbacks.
headline_max_chars: Max characters for condensed headlines.
condenser_soft_limit: Soft limit for content condensation.
condenser_hard_limit: Hard limit for content condensation.
streaming_checkpoint_chars: Chars between streaming checkpoints.
streaming_checkpoint_fragments: Emit checkpoint every N fragments.
streaming_dedup_enabled: Whether to deduplicate consecutive identical fragments.
streaming_checkpoints_enabled: Whether to emit streaming checkpoints.
thinking_preview_min_chars: Min chars for thinking preview.
tool_result_headline_min_chars: Min chars for tool result headline.
"""
console: Console
theme: Theme
width: int
mode: Literal["compact", "medium", "wide"]
narrow: bool
color_enabled: bool
glyphs_enabled: bool
headline_max_chars: int
condenser_soft_limit: int
condenser_hard_limit: int
streaming_checkpoint_chars: int
streaming_checkpoint_fragments: int
streaming_dedup_enabled: bool
streaming_checkpoints_enabled: bool
thinking_preview_min_chars: int
tool_result_headline_min_chars: int
# Captured env mapping used to resolve flags; excluded from equality and hash
env: Mapping[str, str] = field(default_factory=dict, compare=False, repr=False)
# Stored overrides for refreshed() — excluded from equality and hash
_resolved_env: _ResolvedEnv = field(
default_factory=lambda: _ResolvedEnv(
no_color=False,
force_color=False,
force_narrow=False,
columns=None,
force_ascii=False,
streaming_dedup_enabled=True,
streaming_checkpoints_enabled=True,
),
repr=False,
compare=False,
)
_force_width: int | None = field(default=None, repr=False, compare=False)
_force_mode: Literal["compact", "medium", "wide"] | None = field(
default=None, repr=False, compare=False
)
_force_glyphs: bool | None = field(default=None, repr=False, compare=False)
[docs]
def glyph_for(self, name: str) -> str:
"""Return the glyph string for the given logical name.
Args:
name: Logical glyph name (e.g., 'success', 'error', 'milestone', 'arrow').
Returns:
Unicode glyph when glyphs_enabled is True, ASCII fallback otherwise.
Raises:
KeyError: If name is not a known glyph key.
"""
if name not in UNICODE_GLYPHS:
known = ", ".join(sorted(UNICODE_GLYPHS))
raise KeyError(f"Unknown glyph {name!r}. Known glyphs: {known}")
if self.glyphs_enabled:
return UNICODE_GLYPHS[name]
return ASCII_GLYPHS[name]
[docs]
def refreshed(self) -> DisplayContext:
"""Return a new DisplayContext with refreshed terminal width and derived limits.
Re-resolves width and mode using the same precedence rules as make_display_context(),
preserving any active overrides (RALPH_FORCE_NARROW, COLUMNS, force_width, force_mode)
stored at construction time. The console identity, theme, color_enabled,
and glyphs_enabled are unchanged.
Returns:
New DisplayContext with updated width, mode, and limits.
"""
new_width = _compute_width(self._resolved_env, self.console, self._force_width)
new_mode = _compute_mode(self._resolved_env, self._force_mode, new_width)
new_limits = _MODE_LIMITS.get(new_mode, _MODE_LIMITS["wide"])
return DisplayContext(
console=self.console,
theme=self.theme,
width=new_width,
mode=new_mode,
narrow=new_mode == "compact",
color_enabled=self.color_enabled,
glyphs_enabled=self.glyphs_enabled,
headline_max_chars=new_limits.headline_max_chars,
condenser_soft_limit=new_limits.condenser_soft_limit,
condenser_hard_limit=new_limits.condenser_hard_limit,
streaming_checkpoint_chars=new_limits.streaming_checkpoint_chars,
streaming_checkpoint_fragments=self.streaming_checkpoint_fragments,
streaming_dedup_enabled=self.streaming_dedup_enabled,
streaming_checkpoints_enabled=self.streaming_checkpoints_enabled,
thinking_preview_min_chars=new_limits.thinking_preview_min_chars,
tool_result_headline_min_chars=new_limits.tool_result_headline_min_chars,
_resolved_env=self._resolved_env,
env=self.env,
_force_width=self._force_width,
_force_mode=self._force_mode,
_force_glyphs=self._force_glyphs,
)
_MODE_LIMITS: Final[dict[str, _ModeAdaptiveLimits]] = {
"compact": _ModeAdaptiveLimits(
headline_max_chars=COMPACT_HEADLINE_MAX_CHARS,
condenser_soft_limit=COMPACT_CONDENSER_SOFT_LIMIT,
condenser_hard_limit=COMPACT_CONDENSER_HARD_LIMIT,
streaming_checkpoint_chars=COMPACT_STREAMING_CHECKPOINT_CHARS,
thinking_preview_min_chars=COMPACT_THINKING_PREVIEW_MIN_CHARS,
tool_result_headline_min_chars=COMPACT_TOOL_RESULT_HEADLINE_MIN_CHARS,
),
"medium": _ModeAdaptiveLimits(
headline_max_chars=_MEDIUM_HEADLINE_MAX_CHARS,
condenser_soft_limit=_MEDIUM_CONDENSER_SOFT_LIMIT,
condenser_hard_limit=_MEDIUM_CONDENSER_HARD_LIMIT,
streaming_checkpoint_chars=_MEDIUM_STREAMING_CHECKPOINT_CHARS,
thinking_preview_min_chars=_MEDIUM_THINKING_PREVIEW_MIN_CHARS,
tool_result_headline_min_chars=_MEDIUM_TOOL_RESULT_HEADLINE_MIN_CHARS,
),
"wide": _ModeAdaptiveLimits(
headline_max_chars=WIDE_HEADLINE_MAX_CHARS,
condenser_soft_limit=WIDE_CONDENSER_SOFT_LIMIT,
condenser_hard_limit=WIDE_CONDENSER_HARD_LIMIT,
streaming_checkpoint_chars=WIDE_STREAMING_CHECKPOINT_CHARS,
thinking_preview_min_chars=WIDE_THINKING_PREVIEW_MIN_CHARS,
tool_result_headline_min_chars=WIDE_TOOL_RESULT_HEADLINE_MIN_CHARS,
),
}
[docs]
def make_display_context(
*,
env: Mapping[str, str] | None = None,
console: Console | None = None,
force_width: int | None = None,
force_mode: Literal["compact", "medium", "wide"] | None = None,
force_glyphs: bool | None = None,
) -> DisplayContext:
"""Create a DisplayContext with resolved terminal metrics and adaptive limits.
Args:
env: Environment mapping (defaults to os.environ).
console: Console to use (defaults to make_console() with env-aware color policy).
force_width: Override terminal width detection.
force_mode: Override mode detection ('compact', 'medium', or 'wide').
force_glyphs: Override glyph detection (True=Unicode, False=ASCII, None=auto-detect).
Returns:
Fully initialised DisplayContext.
"""
env_dict: dict[str, str] = dict(os.environ if env is None else env)
resolved_env = _resolve_env(env_dict)
resolved_console = console if console is not None else _build_console(resolved_env)
width = _compute_width(resolved_env, resolved_console, force_width)
mode = _compute_mode(resolved_env, force_mode, width)
limits = _MODE_LIMITS.get(mode, _MODE_LIMITS["wide"])
# NO_COLOR wins over FORCE_COLOR per CLI conventions.
color_enabled = not resolved_env.no_color and not _console_has_no_color(resolved_console)
# Glyph capability detection: force_glyphs > RALPH_FORCE_ASCII > stream encoding > TERM=dumb
if force_glyphs is not None:
glyphs_enabled = force_glyphs
else:
glyphs_enabled = detect_glyph_capability(
resolved_console.file if resolved_console.file is not None else sys.stdout,
env_dict,
)
return DisplayContext(
console=resolved_console,
theme=RALPH_THEME,
width=width,
mode=mode,
narrow=mode == "compact",
color_enabled=color_enabled,
glyphs_enabled=glyphs_enabled,
headline_max_chars=limits.headline_max_chars,
condenser_soft_limit=limits.condenser_soft_limit,
condenser_hard_limit=limits.condenser_hard_limit,
streaming_checkpoint_chars=limits.streaming_checkpoint_chars,
streaming_checkpoint_fragments=_STREAMING_CHECKPOINT_FRAGMENTS,
streaming_dedup_enabled=resolved_env.streaming_dedup_enabled,
streaming_checkpoints_enabled=resolved_env.streaming_checkpoints_enabled,
thinking_preview_min_chars=limits.thinking_preview_min_chars,
tool_result_headline_min_chars=limits.tool_result_headline_min_chars,
env=env_dict,
_resolved_env=resolved_env,
_force_width=force_width,
_force_mode=force_mode,
_force_glyphs=force_glyphs,
)
[docs]
def install_sigwinch_refresher(
ctx_holder: list[DisplayContext],
on_refresh: Callable[[DisplayContext], None] | None = None,
) -> None:
"""Install a SIGWINCH handler that refreshes DisplayContext on terminal resize.
On POSIX systems, this installs a signal handler that replaces the
DisplayContext in ctx_holder[0] with a refreshed version that reflects
the new terminal size. An optional callback can keep any long-lived
display objects synced with that refreshed context.
On non-POSIX systems (Windows), this function is a no-op.
Args:
ctx_holder: A single-element list whose 0th element is the DisplayContext
to refresh on SIGWINCH. The handler replaces ctx_holder[0] with
ctx_holder[0].refreshed().
on_refresh: Optional callback invoked with the refreshed context after
ctx_holder[0] is replaced.
Note:
This function must be called from the main thread, as signal.signal
only works in the main thread. If called from a non-main thread,
the function returns silently without installing the handler.
"""
if sys.platform == "win32":
return
if threading.main_thread() is not threading.current_thread():
return
def handler(signum: int, frame: object) -> None:
refreshed = ctx_holder[0].refreshed()
ctx_holder[0] = refreshed
if on_refresh is not None:
on_refresh(refreshed)
signal.signal(signal.SIGWINCH, handler)
[docs]
def install_poll_refresher(
ctx_holder: list[DisplayContext],
interval_seconds: float = 2.0,
on_refresh: Callable[[DisplayContext], None] | None = None,
) -> Callable[[], None]:
"""Start a daemon thread that periodically refreshes DisplayContext.
This provides a fallback for non-POSIX platforms (Windows) where SIGWINCH
is not available, or when called from a non-main thread.
Args:
ctx_holder: A single-element list whose 0th element is the DisplayContext
to refresh periodically. The thread replaces ctx_holder[0] with
ctx_holder[0].refreshed() every interval_seconds.
interval_seconds: How often to refresh (default 2.0s).
on_refresh: Optional callback invoked with the refreshed context after
ctx_holder[0] is replaced.
Returns:
A stop() callable that signals the thread to exit and joins it (1s timeout).
"""
stop_event = threading.Event()
def poll_loop() -> None:
while not stop_event.wait(interval_seconds):
refreshed = ctx_holder[0].refreshed()
ctx_holder[0] = refreshed
if on_refresh is not None:
on_refresh(refreshed)
thread = threading.Thread(target=poll_loop, daemon=True)
thread.start()
def stop() -> None:
stop_event.set()
thread.join(timeout=1.0)
return stop
[docs]
def install_width_refresher(
ctx_holder: list[DisplayContext],
on_refresh: Callable[[DisplayContext], None] | None = None,
) -> Callable[[], None]:
"""Install a width refresher using the best available strategy.
On POSIX main thread: uses SIGWINCH signal handler (install_sigwinch_refresher).
On Windows or non-main thread: falls back to poll-based refresher (install_poll_refresher).
Args:
ctx_holder: A single-element list whose 0th element is the DisplayContext
to refresh on resize.
on_refresh: Optional callback invoked with the refreshed context after
ctx_holder[0] is replaced.
Returns:
A stop() callable (for poll-based refresher; SIGWINCH handler has no cleanup).
"""
if sys.platform != "win32" and threading.main_thread() is threading.current_thread():
install_sigwinch_refresher(ctx_holder, on_refresh)
# SIGWINCH handler cannot be uninstalled, return no-op stop
return lambda: None
return install_poll_refresher(ctx_holder, interval_seconds=2.0, on_refresh=on_refresh)
__all__ = [
"DisplayContext",
"install_poll_refresher",
"install_sigwinch_refresher",
"install_width_refresher",
"make_display_context",
]