Skip to content

Commit c5c1a37

Browse files
committed
feat(plugin): add one-line session summary on stop hook (#1036)
Generate a human-readable one-line summary from tool usage data (files modified, commands run, exploration) displayed in the buddy farewell message. Supports 8 languages (en, ko, ja, zh, es, pt, de, fr) with agent name prefix when active.
1 parent 4a41f3d commit c5c1a37

3 files changed

Lines changed: 293 additions & 1 deletion

File tree

packages/claude-code-plugin/hooks/lib/buddy_renderer.py

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,140 @@ def get_buddy_config(config: Optional[Dict[str, Any]] = None) -> Dict[str, str]:
240240
}
241241

242242

243+
# One-line session summary templates by language (#1036)
244+
ONE_LINE_TEMPLATES: Dict[str, Dict[str, str]] = {
245+
"en": {
246+
"files_and_commands": "{files} {file_word} modified · {commands} commands run",
247+
"files_only": "{files} {file_word} modified",
248+
"commands_only": "{commands} commands run",
249+
"exploration": "Codebase exploration ({reads} files read)",
250+
"minimal": "{tools} tool calls",
251+
"agent_prefix": "{agent}: ",
252+
},
253+
"ko": {
254+
"files_and_commands": "파일 {files}개 수정 · 명령어 {commands}개 실행",
255+
"files_only": "파일 {files}개 수정",
256+
"commands_only": "명령어 {commands}개 실행",
257+
"exploration": "코드베이스 탐색 (파일 {reads}개 읽음)",
258+
"minimal": "도구 {tools}회 호출",
259+
"agent_prefix": "{agent}: ",
260+
},
261+
"ja": {
262+
"files_and_commands": "ファイル{files}件変更 · コマンド{commands}件実行",
263+
"files_only": "ファイル{files}件変更",
264+
"commands_only": "コマンド{commands}件実行",
265+
"exploration": "コードベース探索 (ファイル{reads}件読取)",
266+
"minimal": "ツール{tools}回呼出",
267+
"agent_prefix": "{agent}: ",
268+
},
269+
"zh": {
270+
"files_and_commands": "修改了{files}个文件 · 执行了{commands}个命令",
271+
"files_only": "修改了{files}个文件",
272+
"commands_only": "执行了{commands}个命令",
273+
"exploration": "代码库探索 (读取了{reads}个文件)",
274+
"minimal": "调用了{tools}次工具",
275+
"agent_prefix": "{agent}: ",
276+
},
277+
"es": {
278+
"files_and_commands": "{files} {file_word} modificados · {commands} comandos ejecutados",
279+
"files_only": "{files} {file_word} modificados",
280+
"commands_only": "{commands} comandos ejecutados",
281+
"exploration": "Exploración del código ({reads} archivos leídos)",
282+
"minimal": "{tools} llamadas de herramientas",
283+
"agent_prefix": "{agent}: ",
284+
},
285+
"pt": {
286+
"files_and_commands": "{files} {file_word} modificados · {commands} comandos executados",
287+
"files_only": "{files} {file_word} modificados",
288+
"commands_only": "{commands} comandos executados",
289+
"exploration": "Exploração do código ({reads} arquivos lidos)",
290+
"minimal": "{tools} chamadas de ferramentas",
291+
"agent_prefix": "{agent}: ",
292+
},
293+
"de": {
294+
"files_and_commands": "{files} {file_word} geändert · {commands} Befehle ausgeführt",
295+
"files_only": "{files} {file_word} geändert",
296+
"commands_only": "{commands} Befehle ausgeführt",
297+
"exploration": "Codebase-Erkundung ({reads} Dateien gelesen)",
298+
"minimal": "{tools} Toolaufrufe",
299+
"agent_prefix": "{agent}: ",
300+
},
301+
"fr": {
302+
"files_and_commands": "{files} {file_word} modifiés · {commands} commandes exécutées",
303+
"files_only": "{files} {file_word} modifiés",
304+
"commands_only": "{commands} commandes exécutées",
305+
"exploration": "Exploration du code ({reads} fichiers lus)",
306+
"minimal": "{tools} appels d'outils",
307+
"agent_prefix": "{agent}: ",
308+
},
309+
}
310+
311+
# Singular/plural file word by language
312+
_FILE_WORDS: Dict[str, Dict[str, str]] = {
313+
"en": {"one": "file", "many": "files"},
314+
"es": {"one": "archivo", "many": "archivos"},
315+
"pt": {"one": "arquivo", "many": "arquivos"},
316+
"de": {"one": "Datei", "many": "Dateien"},
317+
"fr": {"one": "fichier", "many": "fichiers"},
318+
}
319+
320+
321+
def generate_one_line_summary(
322+
tool_names: Dict[str, int],
323+
agent_name: str,
324+
language: str,
325+
) -> str:
326+
"""Generate a one-line session summary from tool usage data.
327+
328+
Analyzes tool_names to determine activity type and generates a
329+
human-readable one-line summary with multi-language support.
330+
331+
Args:
332+
tool_names: Dict mapping tool name to usage count
333+
(e.g. {"Edit": 5, "Bash": 10, "Read": 20}).
334+
agent_name: Active agent name, empty string if none.
335+
language: Language code (en, ko, ja, zh, es, pt, de, fr).
336+
337+
Returns:
338+
One-line summary string.
339+
"""
340+
templates = ONE_LINE_TEMPLATES.get(language, ONE_LINE_TEMPLATES["en"])
341+
342+
files_changed = tool_names.get("Edit", 0) + tool_names.get("Write", 0)
343+
commands_run = tool_names.get("Bash", 0)
344+
reads = tool_names.get("Read", 0) + tool_names.get("Grep", 0) + tool_names.get("Glob", 0)
345+
total_tools = sum(tool_names.values())
346+
347+
# Determine file word (singular/plural) for languages that need it
348+
file_words = _FILE_WORDS.get(language, _FILE_WORDS.get("en", {"one": "file", "many": "files"}))
349+
file_word = file_words["one"] if files_changed == 1 else file_words["many"]
350+
351+
# Select appropriate template
352+
if files_changed > 0 and commands_run > 0:
353+
summary = templates["files_and_commands"].format(
354+
files=files_changed, file_word=file_word, commands=commands_run,
355+
)
356+
elif files_changed > 0:
357+
summary = templates["files_only"].format(
358+
files=files_changed, file_word=file_word,
359+
)
360+
elif commands_run > 0:
361+
summary = templates["commands_only"].format(commands=commands_run)
362+
elif reads > 0:
363+
summary = templates["exploration"].format(reads=reads)
364+
elif total_tools > 0:
365+
summary = templates["minimal"].format(tools=total_tools)
366+
else:
367+
summary = templates["minimal"].format(tools=0)
368+
369+
# Prepend agent name if available
370+
if agent_name:
371+
prefix = templates["agent_prefix"].format(agent=agent_name)
372+
summary = prefix + summary
373+
374+
return summary
375+
376+
243377
def _get_greeting(tone: str, language: str) -> str:
244378
"""Get greeting message for given tone and language."""
245379
tone_greetings = GREETINGS.get(tone, GREETINGS["casual"])
@@ -369,6 +503,7 @@ def render_session_summary(
369503
tone: str,
370504
language: str,
371505
buddy_config: Optional[Dict[str, str]] = None,
506+
tool_names: Optional[Dict[str, int]] = None,
372507
) -> str:
373508
"""Render session summary with buddy character for stop hook.
374509
@@ -382,6 +517,8 @@ def render_session_summary(
382517
tone: 'casual' or 'formal'
383518
language: Language code (en, ko, ja, zh, es)
384519
buddy_config: Optional buddy customization from get_buddy_config().
520+
tool_names: Optional dict of tool name to usage count for
521+
one-line summary generation (#1036).
385522
386523
Returns:
387524
Formatted session summary string.
@@ -426,6 +563,13 @@ def render_session_summary(
426563

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

566+
# One-line session summary (#1036)
567+
if tool_names:
568+
agent_name = agents[0]["name"] if agents else ""
569+
one_line = generate_one_line_summary(tool_names, agent_name, language)
570+
if one_line:
571+
parts.append(f"\U0001f4ac {one_line}")
572+
429573
# Active agents section
430574
if agents:
431575
header = _get_summary_header("agents", language)

packages/claude-code-plugin/hooks/stop.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,10 @@ def handle_stop(data: dict):
6969
"colorAnsi": "cyan",
7070
})
7171

72-
rendered = render_session_summary(render_stats, agents, tone, language)
72+
rendered = render_session_summary(
73+
render_stats, agents, tone, language,
74+
tool_names=tool_names,
75+
)
7376
if rendered:
7477
print(rendered, file=sys.stderr)
7578
except Exception:
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
"""Tests for one-line session summary feature (#1036).
2+
3+
Tests generate_one_line_summary() and its integration in render_session_summary().
4+
"""
5+
import os
6+
import sys
7+
8+
import pytest
9+
10+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "hooks", "lib"))
11+
12+
from buddy_renderer import generate_one_line_summary, render_session_summary
13+
14+
15+
class TestGenerateOneLineSummary:
16+
"""Tests for generate_one_line_summary() function."""
17+
18+
def test_files_and_commands(self):
19+
"""Should generate summary with files modified and commands run."""
20+
tool_names = {"Edit": 5, "Write": 2, "Bash": 10, "Read": 20}
21+
result = generate_one_line_summary(tool_names, "", "en")
22+
assert "7 files modified" in result
23+
assert "10 commands" in result
24+
25+
def test_files_only(self):
26+
"""Should generate summary with only file modifications."""
27+
tool_names = {"Edit": 3, "Read": 10}
28+
result = generate_one_line_summary(tool_names, "", "en")
29+
assert "3 files modified" in result
30+
assert "command" not in result
31+
32+
def test_commands_only(self):
33+
"""Should generate summary with only commands run."""
34+
tool_names = {"Bash": 8, "Read": 5}
35+
result = generate_one_line_summary(tool_names, "", "en")
36+
assert "8 commands" in result
37+
assert "file" not in result.lower() or "modified" not in result
38+
39+
def test_exploration_only(self):
40+
"""Should generate exploration summary when only reads/greps."""
41+
tool_names = {"Read": 15, "Grep": 8, "Glob": 3}
42+
result = generate_one_line_summary(tool_names, "", "en")
43+
assert result # Should produce something, not empty
44+
45+
def test_empty_tool_names(self):
46+
"""Should handle empty tool_names gracefully."""
47+
result = generate_one_line_summary({}, "", "en")
48+
assert result # Should still return something
49+
50+
def test_with_agent_name(self):
51+
"""Should include agent name in summary."""
52+
tool_names = {"Edit": 4, "Bash": 6}
53+
result = generate_one_line_summary(tool_names, "Frontend Developer", "en")
54+
assert "Frontend Developer" in result
55+
56+
def test_korean_language(self):
57+
"""Should generate Korean summary."""
58+
tool_names = {"Edit": 3, "Write": 1, "Bash": 5}
59+
result = generate_one_line_summary(tool_names, "", "ko")
60+
assert "수정" in result or "파일" in result
61+
62+
def test_japanese_language(self):
63+
"""Should generate Japanese summary."""
64+
tool_names = {"Edit": 2, "Bash": 3}
65+
result = generate_one_line_summary(tool_names, "", "ja")
66+
assert result
67+
# Should not be English fallback tokens only
68+
assert any(ord(c) > 127 for c in result)
69+
70+
def test_chinese_language(self):
71+
"""Should generate Chinese summary."""
72+
tool_names = {"Edit": 2, "Bash": 3}
73+
result = generate_one_line_summary(tool_names, "", "zh")
74+
assert result
75+
assert any(ord(c) > 127 for c in result)
76+
77+
def test_spanish_language(self):
78+
"""Should generate Spanish summary."""
79+
tool_names = {"Edit": 2, "Bash": 3}
80+
result = generate_one_line_summary(tool_names, "", "es")
81+
assert result
82+
83+
def test_unknown_language_falls_back_to_english(self):
84+
"""Should fall back to English for unknown language codes."""
85+
tool_names = {"Edit": 5, "Bash": 3}
86+
result = generate_one_line_summary(tool_names, "", "xx")
87+
en_result = generate_one_line_summary(tool_names, "", "en")
88+
assert result == en_result
89+
90+
def test_single_file_singular(self):
91+
"""Should use singular form for 1 file."""
92+
tool_names = {"Edit": 1}
93+
result = generate_one_line_summary(tool_names, "", "en")
94+
assert "1 file modified" in result
95+
assert "files" not in result
96+
97+
def test_agent_with_korean(self):
98+
"""Should include agent name with Korean summary."""
99+
tool_names = {"Edit": 3, "Bash": 5}
100+
result = generate_one_line_summary(tool_names, "Backend Developer", "ko")
101+
assert "Backend Developer" in result
102+
assert ("수정" in result or "파일" in result)
103+
104+
105+
class TestRenderSessionSummaryWithOneLine:
106+
"""Tests for one-line summary integration in render_session_summary()."""
107+
108+
def test_includes_one_line_summary(self):
109+
"""Should include one-line summary in rendered output."""
110+
stats = {"duration_minutes": 10, "tool_count": 20, "files_changed": 5}
111+
tool_names = {"Edit": 3, "Write": 2, "Bash": 10}
112+
agents = []
113+
result = render_session_summary(
114+
stats, agents, "casual", "en", tool_names=tool_names,
115+
)
116+
assert "5 files modified" in result or "files modified" in result
117+
118+
def test_no_summary_without_tool_names(self):
119+
"""Should still work without tool_names (backward compatible)."""
120+
stats = {"duration_minutes": 5, "tool_count": 10, "files_changed": 3}
121+
agents = []
122+
result = render_session_summary(stats, agents, "casual", "en")
123+
# Should render without error, just no one-line summary
124+
assert "Session Summary" in result or "세션 요약" in result or result
125+
126+
def test_summary_with_agent_in_output(self):
127+
"""Should include agent context in one-line summary."""
128+
stats = {"duration_minutes": 10, "tool_count": 15, "files_changed": 4}
129+
tool_names = {"Edit": 4, "Bash": 8}
130+
agents = [{"name": "Test Engineer", "eye": "●", "colorAnsi": "green"}]
131+
result = render_session_summary(
132+
stats, agents, "casual", "en", tool_names=tool_names,
133+
)
134+
# The one-line summary should be present
135+
assert "files modified" in result or "commands" in result
136+
137+
def test_korean_session_summary(self):
138+
"""Should render Korean one-line summary."""
139+
stats = {"duration_minutes": 5, "tool_count": 8, "files_changed": 2}
140+
tool_names = {"Edit": 2, "Bash": 4}
141+
agents = []
142+
result = render_session_summary(
143+
stats, agents, "casual", "ko", tool_names=tool_names,
144+
)
145+
assert ("수정" in result or "파일" in result)

0 commit comments

Comments
 (0)