diff --git a/packages/claude-code-plugin/hooks/lib/buddy_renderer.py b/packages/claude-code-plugin/hooks/lib/buddy_renderer.py index e116398f..4b53236b 100644 --- a/packages/claude-code-plugin/hooks/lib/buddy_renderer.py +++ b/packages/claude-code-plugin/hooks/lib/buddy_renderer.py @@ -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"]) @@ -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. @@ -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. @@ -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) diff --git a/packages/claude-code-plugin/hooks/stop.py b/packages/claude-code-plugin/hooks/stop.py index 0d1e2441..d00cb6bd 100644 --- a/packages/claude-code-plugin/hooks/stop.py +++ b/packages/claude-code-plugin/hooks/stop.py @@ -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: diff --git a/packages/claude-code-plugin/tests/test_session_summary.py b/packages/claude-code-plugin/tests/test_session_summary.py new file mode 100644 index 00000000..704229a3 --- /dev/null +++ b/packages/claude-code-plugin/tests/test_session_summary.py @@ -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)