Skip to content
Merged
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
68 changes: 68 additions & 0 deletions packages/claude-code-plugin/hooks/lib/tiny_actor_renderer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
"""Width-safe Tiny Actor card renderer (#1269).

Renders a compact 2-3 line card (face, label, optional quote) using
display-width-safe helpers from ``buddy_renderer``. Every output line
is guaranteed to have exactly ``card_width`` display columns.
"""
from __future__ import annotations

from typing import List

from tiny_actor_card import TinyActorCard
from buddy_renderer import (
display_width,
pad_to_display_width,
truncate_to_display_width,
)


def _center_to_width(text: str, width: int) -> str:
"""Center *text* within *width* display columns.

Left-pads with spaces so the visible content sits roughly in the
middle, then right-pads to exactly *width*.
"""
text_w = display_width(text)
if text_w >= width:
return pad_to_display_width(truncate_to_display_width(text, width), width)
left_pad = (width - text_w) // 2
return pad_to_display_width((" " * left_pad) + text, width)


def render_card(
card: TinyActorCard,
*,
card_width: int = 14,
show_quote: bool = True,
) -> List[str]:
"""Render a :class:`TinyActorCard` as a list of display-width-safe lines.

Parameters
----------
card:
The actor card to render.
card_width:
Target display width for every line (default 14).
show_quote:
When ``False``, the quote line is always omitted even if the
card has a quote.

Returns
-------
list[str]
2 or 3 lines, each exactly *card_width* display columns wide.
"""
lines: List[str] = []

# Line 1: face (centered)
lines.append(_center_to_width(card.face, card_width))

# Line 2: label (centered, truncated if too wide)
lines.append(_center_to_width(card.label, card_width))

# Line 3 (optional): quote
if show_quote and card.quote is not None:
truncated = truncate_to_display_width(card.quote, card_width)
lines.append(pad_to_display_width(truncated, card_width))

return lines
205 changes: 205 additions & 0 deletions packages/claude-code-plugin/tests/test_tiny_actor_renderer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
"""Tests for width-safe Tiny Actor card renderer (#1269)."""
import os
import sys

import pytest

# Ensure hooks/lib is on path
_tests_dir = os.path.dirname(os.path.abspath(__file__))
_lib_dir = os.path.join(os.path.dirname(_tests_dir), "hooks", "lib")
if _lib_dir not in sys.path:
sys.path.insert(0, _lib_dir)

from tiny_actor_card import TinyActorCard, create_actor_card
from tiny_actor_renderer import render_card
from buddy_renderer import display_width, strip_ansi

from typing import Optional


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

def _make_card(
label: str = "Security",
mood: str = "speaking",
quote: "Optional[str]" = "Checking…",
color_ansi: "Optional[str]" = None,
ascii_mode: bool = False,
) -> TinyActorCard:
return create_actor_card(
agent_id="test-agent",
label=label,
mood=mood,
quote=quote,
color_ansi=color_ansi,
ascii_mode=ascii_mode,
)


# ---------------------------------------------------------------------------
# Basic rendering
# ---------------------------------------------------------------------------


class TestBasicRendering:
"""render_card returns the correct number of lines."""

def test_card_with_quote_returns_three_lines(self):
card = _make_card(quote="Hello")
lines = render_card(card)
assert len(lines) == 3

def test_card_without_quote_returns_two_lines(self):
card = _make_card(quote=None)
lines = render_card(card)
assert len(lines) == 2

def test_show_quote_false_omits_quote_line(self):
card = _make_card(quote="Hello")
lines = render_card(card, show_quote=False)
assert len(lines) == 2


# ---------------------------------------------------------------------------
# Display-width safety
# ---------------------------------------------------------------------------


class TestDisplayWidthSafety:
"""All lines have equal display width matching card_width."""

def test_all_lines_equal_display_width(self):
card = _make_card()
lines = render_card(card, card_width=14)
widths = [display_width(line) for line in lines]
assert all(w == 14 for w in widths), f"widths: {widths}"

def test_custom_card_width(self):
card = _make_card()
lines = render_card(card, card_width=20)
widths = [display_width(line) for line in lines]
assert all(w == 20 for w in widths), f"widths: {widths}"

def test_narrow_card_width(self):
card = _make_card(label="X", quote=None)
lines = render_card(card, card_width=6)
widths = [display_width(line) for line in lines]
assert all(w == 6 for w in widths), f"widths: {widths}"


# ---------------------------------------------------------------------------
# CJK and multibyte text
# ---------------------------------------------------------------------------


class TestCJKTruncation:
"""CJK labels and quotes are truncated to fit display width."""

def test_cjk_label_truncated_correctly(self):
card = _make_card(label="보안전문가입니다", quote=None)
lines = render_card(card, card_width=14)
widths = [display_width(line) for line in lines]
assert all(w == 14 for w in widths), f"widths: {widths}"

def test_japanese_label(self):
card = _make_card(label="セキュリティ", quote=None)
lines = render_card(card, card_width=14)
widths = [display_width(line) for line in lines]
assert all(w == 14 for w in widths), f"widths: {widths}"

def test_cjk_quote_truncated(self):
card = _make_card(label="Sec", quote="한글 인용 테스트입니다")
lines = render_card(card, card_width=14)
widths = [display_width(line) for line in lines]
assert all(w == 14 for w in widths), f"widths: {widths}"


# ---------------------------------------------------------------------------
# ANSI-colored faces
# ---------------------------------------------------------------------------


class TestANSIColoredFace:
"""ANSI escape codes in face don't break alignment."""

def test_ansi_face_does_not_break_width(self):
card = _make_card(color_ansi="red")
# Wrap face in ANSI manually to simulate colored face
colored_face = f"\033[31m{card.face}\033[0m"
colored_card = TinyActorCard(
agent_id=card.agent_id,
label=card.label,
face=colored_face,
eye=card.eye,
mood=card.mood,
quote=card.quote,
color_ansi=card.color_ansi,
is_moderator=card.is_moderator,
)
lines = render_card(colored_card, card_width=14)
widths = [display_width(line) for line in lines]
assert all(w == 14 for w in widths), f"widths: {widths}"

def test_ansi_stripped_face_has_visible_content(self):
card = _make_card()
colored_face = f"\033[32m{card.face}\033[0m"
colored_card = TinyActorCard(
agent_id=card.agent_id,
label=card.label,
face=colored_face,
eye=card.eye,
mood=card.mood,
quote=card.quote,
color_ansi=card.color_ansi,
is_moderator=card.is_moderator,
)
lines = render_card(colored_card, card_width=14)
# Face line should contain visible characters
assert len(strip_ansi(lines[0]).strip()) > 0


# ---------------------------------------------------------------------------
# ASCII mode
# ---------------------------------------------------------------------------


class TestASCIIMode:
"""ASCII mode produces only ASCII characters in face."""

def test_ascii_mode_face_is_ascii_only(self):
card = _make_card(ascii_mode=True)
lines = render_card(card, card_width=14)
face_line = strip_ansi(lines[0])
assert face_line.isascii(), f"non-ASCII in face: {face_line!r}"

def test_ascii_mode_display_width_correct(self):
card = _make_card(ascii_mode=True)
lines = render_card(card, card_width=14)
widths = [display_width(line) for line in lines]
assert all(w == 14 for w in widths), f"widths: {widths}"


# ---------------------------------------------------------------------------
# Content correctness
# ---------------------------------------------------------------------------


class TestContentCorrectness:
"""Rendered lines contain expected content."""

def test_face_appears_in_first_line(self):
card = _make_card()
lines = render_card(card, card_width=14)
assert card.face in lines[0]

def test_label_appears_in_second_line(self):
card = _make_card(label="Test")
lines = render_card(card, card_width=14)
assert "Test" in lines[1]

def test_quote_appears_in_third_line(self):
card = _make_card(quote="Hi")
lines = render_card(card, card_width=14)
assert "Hi" in lines[2]
Loading