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
144 changes: 144 additions & 0 deletions packages/claude-code-plugin/hooks/lib/buddy_renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,140 @@ def get_buddy_config(config: Optional[Dict[str, Any]] = None) -> Dict[str, str]:
}


# One-line session summary templates by language (#1036)
ONE_LINE_TEMPLATES: Dict[str, Dict[str, str]] = {
"en": {
"files_and_commands": "{files} {file_word} modified · {commands} commands run",
"files_only": "{files} {file_word} modified",
"commands_only": "{commands} commands run",
"exploration": "Codebase exploration ({reads} files read)",
"minimal": "{tools} tool calls",
"agent_prefix": "{agent}: ",
},
"ko": {
"files_and_commands": "파일 {files}개 수정 · 명령어 {commands}개 실행",
"files_only": "파일 {files}개 수정",
"commands_only": "명령어 {commands}개 실행",
"exploration": "코드베이스 탐색 (파일 {reads}개 읽음)",
"minimal": "도구 {tools}회 호출",
"agent_prefix": "{agent}: ",
},
"ja": {
"files_and_commands": "ファイル{files}件変更 · コマンド{commands}件実行",
"files_only": "ファイル{files}件変更",
"commands_only": "コマンド{commands}件実行",
"exploration": "コードベース探索 (ファイル{reads}件読取)",
"minimal": "ツール{tools}回呼出",
"agent_prefix": "{agent}: ",
},
"zh": {
"files_and_commands": "修改了{files}个文件 · 执行了{commands}个命令",
"files_only": "修改了{files}个文件",
"commands_only": "执行了{commands}个命令",
"exploration": "代码库探索 (读取了{reads}个文件)",
"minimal": "调用了{tools}次工具",
"agent_prefix": "{agent}: ",
},
"es": {
"files_and_commands": "{files} {file_word} modificados · {commands} comandos ejecutados",
"files_only": "{files} {file_word} modificados",
"commands_only": "{commands} comandos ejecutados",
"exploration": "Exploración del código ({reads} archivos leídos)",
"minimal": "{tools} llamadas de herramientas",
"agent_prefix": "{agent}: ",
},
"pt": {
"files_and_commands": "{files} {file_word} modificados · {commands} comandos executados",
"files_only": "{files} {file_word} modificados",
"commands_only": "{commands} comandos executados",
"exploration": "Exploração do código ({reads} arquivos lidos)",
"minimal": "{tools} chamadas de ferramentas",
"agent_prefix": "{agent}: ",
},
"de": {
"files_and_commands": "{files} {file_word} geändert · {commands} Befehle ausgeführt",
"files_only": "{files} {file_word} geändert",
"commands_only": "{commands} Befehle ausgeführt",
"exploration": "Codebase-Erkundung ({reads} Dateien gelesen)",
"minimal": "{tools} Toolaufrufe",
"agent_prefix": "{agent}: ",
},
"fr": {
"files_and_commands": "{files} {file_word} modifiés · {commands} commandes exécutées",
"files_only": "{files} {file_word} modifiés",
"commands_only": "{commands} commandes exécutées",
"exploration": "Exploration du code ({reads} fichiers lus)",
"minimal": "{tools} appels d'outils",
"agent_prefix": "{agent}: ",
},
}

# Singular/plural file word by language
_FILE_WORDS: Dict[str, Dict[str, str]] = {
"en": {"one": "file", "many": "files"},
"es": {"one": "archivo", "many": "archivos"},
"pt": {"one": "arquivo", "many": "arquivos"},
"de": {"one": "Datei", "many": "Dateien"},
"fr": {"one": "fichier", "many": "fichiers"},
}


def generate_one_line_summary(
tool_names: Dict[str, int],
agent_name: str,
language: str,
) -> str:
"""Generate a one-line session summary from tool usage data.

Analyzes tool_names to determine activity type and generates a
human-readable one-line summary with multi-language support.

Args:
tool_names: Dict mapping tool name to usage count
(e.g. {"Edit": 5, "Bash": 10, "Read": 20}).
agent_name: Active agent name, empty string if none.
language: Language code (en, ko, ja, zh, es, pt, de, fr).

Returns:
One-line summary string.
"""
templates = ONE_LINE_TEMPLATES.get(language, ONE_LINE_TEMPLATES["en"])

files_changed = tool_names.get("Edit", 0) + tool_names.get("Write", 0)
commands_run = tool_names.get("Bash", 0)
reads = tool_names.get("Read", 0) + tool_names.get("Grep", 0) + tool_names.get("Glob", 0)
total_tools = sum(tool_names.values())

# Determine file word (singular/plural) for languages that need it
file_words = _FILE_WORDS.get(language, _FILE_WORDS.get("en", {"one": "file", "many": "files"}))
file_word = file_words["one"] if files_changed == 1 else file_words["many"]

# Select appropriate template
if files_changed > 0 and commands_run > 0:
summary = templates["files_and_commands"].format(
files=files_changed, file_word=file_word, commands=commands_run,
)
elif files_changed > 0:
summary = templates["files_only"].format(
files=files_changed, file_word=file_word,
)
elif commands_run > 0:
summary = templates["commands_only"].format(commands=commands_run)
elif reads > 0:
summary = templates["exploration"].format(reads=reads)
elif total_tools > 0:
summary = templates["minimal"].format(tools=total_tools)
else:
summary = templates["minimal"].format(tools=0)

# Prepend agent name if available
if agent_name:
prefix = templates["agent_prefix"].format(agent=agent_name)
summary = prefix + summary

return summary


def _get_greeting(tone: str, language: str) -> str:
"""Get greeting message for given tone and language."""
tone_greetings = GREETINGS.get(tone, GREETINGS["casual"])
Expand Down Expand Up @@ -369,6 +503,7 @@ def render_session_summary(
tone: str,
language: str,
buddy_config: Optional[Dict[str, str]] = None,
tool_names: Optional[Dict[str, int]] = None,
) -> str:
"""Render session summary with buddy character for stop hook.

Expand All @@ -382,6 +517,8 @@ def render_session_summary(
tone: 'casual' or 'formal'
language: Language code (en, ko, ja, zh, es)
buddy_config: Optional buddy customization from get_buddy_config().
tool_names: Optional dict of tool name to usage count for
one-line summary generation (#1036).

Returns:
Formatted session summary string.
Expand Down Expand Up @@ -426,6 +563,13 @@ def render_session_summary(

parts.append(" \u2502 ".join(stat_items))

# One-line session summary (#1036)
if tool_names:
agent_name = agents[0]["name"] if agents else ""
one_line = generate_one_line_summary(tool_names, agent_name, language)
if one_line:
parts.append(f"\U0001f4ac {one_line}")

# Active agents section
if agents:
header = _get_summary_header("agents", language)
Expand Down
5 changes: 4 additions & 1 deletion packages/claude-code-plugin/hooks/stop.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,10 @@ def handle_stop(data: dict):
"colorAnsi": "cyan",
})

rendered = render_session_summary(render_stats, agents, tone, language)
rendered = render_session_summary(
render_stats, agents, tone, language,
tool_names=tool_names,
)
if rendered:
print(rendered, file=sys.stderr)
except Exception:
Expand Down
145 changes: 145 additions & 0 deletions packages/claude-code-plugin/tests/test_session_summary.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
"""Tests for one-line session summary feature (#1036).

Tests generate_one_line_summary() and its integration in render_session_summary().
"""
import os
import sys

import pytest

sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "hooks", "lib"))

from buddy_renderer import generate_one_line_summary, render_session_summary


class TestGenerateOneLineSummary:
"""Tests for generate_one_line_summary() function."""

def test_files_and_commands(self):
"""Should generate summary with files modified and commands run."""
tool_names = {"Edit": 5, "Write": 2, "Bash": 10, "Read": 20}
result = generate_one_line_summary(tool_names, "", "en")
assert "7 files modified" in result
assert "10 commands" in result

def test_files_only(self):
"""Should generate summary with only file modifications."""
tool_names = {"Edit": 3, "Read": 10}
result = generate_one_line_summary(tool_names, "", "en")
assert "3 files modified" in result
assert "command" not in result

def test_commands_only(self):
"""Should generate summary with only commands run."""
tool_names = {"Bash": 8, "Read": 5}
result = generate_one_line_summary(tool_names, "", "en")
assert "8 commands" in result
assert "file" not in result.lower() or "modified" not in result

def test_exploration_only(self):
"""Should generate exploration summary when only reads/greps."""
tool_names = {"Read": 15, "Grep": 8, "Glob": 3}
result = generate_one_line_summary(tool_names, "", "en")
assert result # Should produce something, not empty

def test_empty_tool_names(self):
"""Should handle empty tool_names gracefully."""
result = generate_one_line_summary({}, "", "en")
assert result # Should still return something

def test_with_agent_name(self):
"""Should include agent name in summary."""
tool_names = {"Edit": 4, "Bash": 6}
result = generate_one_line_summary(tool_names, "Frontend Developer", "en")
assert "Frontend Developer" in result

def test_korean_language(self):
"""Should generate Korean summary."""
tool_names = {"Edit": 3, "Write": 1, "Bash": 5}
result = generate_one_line_summary(tool_names, "", "ko")
assert "수정" in result or "파일" in result

def test_japanese_language(self):
"""Should generate Japanese summary."""
tool_names = {"Edit": 2, "Bash": 3}
result = generate_one_line_summary(tool_names, "", "ja")
assert result
# Should not be English fallback tokens only
assert any(ord(c) > 127 for c in result)

def test_chinese_language(self):
"""Should generate Chinese summary."""
tool_names = {"Edit": 2, "Bash": 3}
result = generate_one_line_summary(tool_names, "", "zh")
assert result
assert any(ord(c) > 127 for c in result)

def test_spanish_language(self):
"""Should generate Spanish summary."""
tool_names = {"Edit": 2, "Bash": 3}
result = generate_one_line_summary(tool_names, "", "es")
assert result

def test_unknown_language_falls_back_to_english(self):
"""Should fall back to English for unknown language codes."""
tool_names = {"Edit": 5, "Bash": 3}
result = generate_one_line_summary(tool_names, "", "xx")
en_result = generate_one_line_summary(tool_names, "", "en")
assert result == en_result

def test_single_file_singular(self):
"""Should use singular form for 1 file."""
tool_names = {"Edit": 1}
result = generate_one_line_summary(tool_names, "", "en")
assert "1 file modified" in result
assert "files" not in result

def test_agent_with_korean(self):
"""Should include agent name with Korean summary."""
tool_names = {"Edit": 3, "Bash": 5}
result = generate_one_line_summary(tool_names, "Backend Developer", "ko")
assert "Backend Developer" in result
assert ("수정" in result or "파일" in result)


class TestRenderSessionSummaryWithOneLine:
"""Tests for one-line summary integration in render_session_summary()."""

def test_includes_one_line_summary(self):
"""Should include one-line summary in rendered output."""
stats = {"duration_minutes": 10, "tool_count": 20, "files_changed": 5}
tool_names = {"Edit": 3, "Write": 2, "Bash": 10}
agents = []
result = render_session_summary(
stats, agents, "casual", "en", tool_names=tool_names,
)
assert "5 files modified" in result or "files modified" in result

def test_no_summary_without_tool_names(self):
"""Should still work without tool_names (backward compatible)."""
stats = {"duration_minutes": 5, "tool_count": 10, "files_changed": 3}
agents = []
result = render_session_summary(stats, agents, "casual", "en")
# Should render without error, just no one-line summary
assert "Session Summary" in result or "세션 요약" in result or result

def test_summary_with_agent_in_output(self):
"""Should include agent context in one-line summary."""
stats = {"duration_minutes": 10, "tool_count": 15, "files_changed": 4}
tool_names = {"Edit": 4, "Bash": 8}
agents = [{"name": "Test Engineer", "eye": "●", "colorAnsi": "green"}]
result = render_session_summary(
stats, agents, "casual", "en", tool_names=tool_names,
)
# The one-line summary should be present
assert "files modified" in result or "commands" in result

def test_korean_session_summary(self):
"""Should render Korean one-line summary."""
stats = {"duration_minutes": 5, "tool_count": 8, "files_changed": 2}
tool_names = {"Edit": 2, "Bash": 4}
agents = []
result = render_session_summary(
stats, agents, "casual", "ko", tool_names=tool_names,
)
assert ("수정" in result or "파일" in result)
Loading