Skip to content
Closed
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
49 changes: 49 additions & 0 deletions claude_code_log/factories/user_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
TaskNotificationMessage,
TeammateMessage,
TextContent,
UserHookNotificationMessage,
UserMemoryMessage,
UserSlashCommandMessage,
UserTextMessage,
Expand All @@ -59,6 +60,46 @@ def is_command_message(text_content: str) -> bool:
return "<command-name>" in text_content and "<command-message>" in text_content


# Hook-injected synthetic user turns from clmail / clmail-monitor.
# These arrive in the JSONL as full ``type: user`` entries — the
# ``UserPromptSubmit`` hook submits a one-liner verbatim — but they're
# system signals (e.g. ``[monitor] alice idle``,
# ``[clmail] You've got a new mail (#3017)``), not human prompts.
#
# Detection is single-line content-prefix matching: the JSONL carries
# no structural marker distinguishing hook-injected user turns from
# real user prompts. The match anchors on the *whole* message: any
# extra content after the bracketed prefix line falls back to a regular
# ``UserTextMessage`` (so a real user typing "[monitor] foo\n\nactual
# prompt" is preserved).
#
# ``[Request interrupted by user]`` / ``[Image #N]`` / ``[@filename]``
# are deliberately *not* matched: they are real interaction markers
# generated by Claude Code itself, not external tooling.
_HOOK_NOTIFICATION_SOURCES: tuple[str, ...] = ("monitor", "clmail")
_HOOK_NOTIFICATION_RE = re.compile(
r"^\s*\[(" + "|".join(_HOOK_NOTIFICATION_SOURCES) + r")\]\s*(.*?)\s*\Z",
re.DOTALL,
)


def detect_hook_notification(text_content: str) -> Optional[tuple[str, str]]:
"""Return ``(source, body)`` if ``text_content`` is a hook one-liner.

Returns ``None`` if there's any newline-separated content beyond
the bracketed prefix line — that indicates a human-typed prompt
that happens to start with one of the recognised bracket prefixes.
"""
match = _HOOK_NOTIFICATION_RE.match(text_content)
if match is None:
return None
body = match.group(2)
# Reject multi-line: real hook injections are single-line.
if "\n" in body:
return None
return match.group(1), body


def is_local_command_output(text_content: str) -> bool:
"""Check if a message contains local command output."""
return "<local-command-stdout>" in text_content
Expand Down Expand Up @@ -459,6 +500,7 @@ def create_user_memory_message(
UserTextMessage,
TeammateMessage,
TaskNotificationMessage,
UserHookNotificationMessage,
]


Expand Down Expand Up @@ -518,6 +560,13 @@ def create_user_message(
if notification := create_task_notification_message(meta, text_content):
return notification

# Hook-injected synthetic user turns (clmail / monitor one-liners).
# Wrapped in a typed content class so the render path can drop them
# at HIGH/LOW/MINIMAL/USER_ONLY alongside other hook noise.
if hook := detect_hook_notification(text_content):
source, body = hook
return UserHookNotificationMessage(source=source, text=body, meta=meta)

# Slash command expanded prompts - combine all text as markdown
if is_slash_command:
all_text = "\n\n".join(
Expand Down
7 changes: 7 additions & 0 deletions claude_code_log/html/renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
ToolUseMessage,
TranscriptEntry,
UnknownMessage,
UserHookNotificationMessage,
UserMemoryMessage,
UserSlashCommandMessage,
UserTextMessage,
Expand Down Expand Up @@ -113,6 +114,7 @@
format_command_output_content,
format_compacted_summary_content,
format_slash_command_content,
format_user_hook_notification_content,
format_user_memory_content,
format_user_slash_command_content,
format_user_text_model_content,
Expand Down Expand Up @@ -395,6 +397,11 @@ def format_UserMemoryMessage(
) -> str:
return format_user_memory_content(content)

def format_UserHookNotificationMessage(
self, content: UserHookNotificationMessage, _: TemplateMessage
) -> str:
return format_user_hook_notification_content(content)

def format_TeammateMessage(
self, content: TeammateMessage, _: TemplateMessage
) -> str:
Expand Down
19 changes: 19 additions & 0 deletions claude_code_log/html/templates/components/message_styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,25 @@ div.system-hook .header {
font-weight: 500;
}

/* Hook-injected synthetic user turns (clmail / monitor one-liners).
At FULL detail these reach the renderer; at HIGH and below they're
filtered out by `_HIGH_EXCLUDE_CLASSES`. Render with the same
"secondary signal" weight as the other hook entries above. */
div.hook-notification {
font-size: 75%;
opacity: 65%;
border-left-color: var(--warning-dimmed);
}

div.hook-notification .content {
font-family: var(--font-monospace);
}

.hook-notification-source {
color: var(--text-muted);
font-weight: 600;
}

.hook-attachment {
cursor: pointer;
}
Expand Down
22 changes: 22 additions & 0 deletions claude_code_log/html/user_formatters.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
IdeSelection,
ImageContent,
SlashCommandMessage,
UserHookNotificationMessage,
UserMemoryMessage,
UserSlashCommandMessage,
UserTextMessage,
Expand Down Expand Up @@ -308,6 +309,26 @@ def format_user_memory_content(content: UserMemoryMessage) -> str:
return f"<pre>{escaped_text}</pre>"


def format_user_hook_notification_content(
content: UserHookNotificationMessage,
) -> str:
"""Format a hook-injected notification (clmail / monitor) as a compact line.

These reach the renderer only at ``DetailLevel.FULL`` (the
``_HIGH_EXCLUDE_CLASSES`` filter drops them otherwise). Even at
FULL we render them as a single-line marker rather than a full
user heading — they're signals, not prompts.
"""
source = escape_html(content.source)
text = escape_html(content.text)
return (
f'<div class="hook-notification hook-notification-{source}">'
f'<span class="hook-notification-source">[{source}]</span> '
f'<span class="hook-notification-text">{text}</span>'
f"</div>"
)


def format_user_slash_command_content(content: UserSlashCommandMessage) -> str:
"""Format slash command expanded prompt (isMeta) as HTML.

Expand Down Expand Up @@ -424,5 +445,6 @@ def format_ide_notification_content(content: IdeNotificationContent) -> list[str
"format_user_text_model_content",
"format_compacted_summary_content",
"format_user_memory_content",
"format_user_hook_notification_content",
"format_ide_notification_content",
]
6 changes: 6 additions & 0 deletions claude_code_log/html/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
ToolResultMessage,
ToolUseMessage,
UnknownMessage,
UserHookNotificationMessage,
UserMemoryMessage,
UserSlashCommandMessage,
UserSteeringMessage,
Expand Down Expand Up @@ -69,6 +70,7 @@
SlashCommandMessage: ["user", "slash-command"],
UserSlashCommandMessage: ["user", "slash-command"],
UserMemoryMessage: ["user"],
UserHookNotificationMessage: ["user", "hook-notification"],
CompactedSummaryMessage: ["user", "compacted"],
CommandOutputMessage: ["user", "command-output"],
TeammateMessage: ["user", "teammate"],
Expand Down Expand Up @@ -181,6 +183,10 @@ def get_message_emoji(msg: "TemplateMessage") -> str:
# Command output has no emoji (neutral - can be from built-in or user command)
if isinstance(msg.content, CommandOutputMessage):
return ""
# Hook-injected notifications (clmail / monitor) render compactly
# at FULL detail and are dropped at HIGH and below — no 🤷 chrome.
if isinstance(msg.content, UserHookNotificationMessage):
return ""
return "🤷"
elif msg_type == "bash-input":
return "💻"
Expand Down
20 changes: 20 additions & 0 deletions claude_code_log/markdown/renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
ToolUseMessage,
TranscriptEntry,
UnknownMessage,
UserHookNotificationMessage,
UserMemoryMessage,
UserSlashCommandMessage,
UserTextMessage,
Expand Down Expand Up @@ -854,6 +855,18 @@ def format_UserMemoryMessage(
"""Format → fenced code block."""
return self._code_fence(content.memory_text)

def format_UserHookNotificationMessage(
self, content: UserHookNotificationMessage, _: TemplateMessage
) -> str:
"""Format → single italic line, e.g. ``*[monitor] alice idle*``.

Hook-injected synthetic user turns are dropped entirely at
``DetailLevel.HIGH`` and below by ``_HIGH_EXCLUDE_CLASSES``;
only ``FULL`` reaches this formatter. Rendered as a compact
marker rather than a full user heading.
"""
return f"*[{content.source}] {content.text}*"

def format_TeammateMessage(
self, content: TeammateMessage, message: TemplateMessage
) -> str:
Expand Down Expand Up @@ -1942,6 +1955,13 @@ def _render_message(self, msg: TemplateMessage, level: int) -> str:
# Format content (if not already output above)
if content:
parts.append(content)
elif content:
# Headless content (e.g. UserHookNotificationMessage at FULL).
# Standalone messages with an empty title used to drop their
# body silently — paired partners short-circuit earlier
# (``is_middle_in_pair``/``is_last_in_pair``), so anything
# reaching here is a real content carrier.
parts.append(content)

# Format paired message bodies (middle then last, when present).
# Triples (slash-command META → CMD → OUT) deliver three bodies
Expand Down
30 changes: 30 additions & 0 deletions claude_code_log/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -656,6 +656,36 @@ def message_type(self) -> str:
return "user"


@dataclass
class UserHookNotificationMessage(MessageContent):
"""Content for hook-injected synthetic user turns (e.g. clmail / monitor).

External tooling (``clmail``, ``clmail-monitor``, ...) uses Claude
Code's ``UserPromptSubmit`` hook to inject single-line notifications
that arrive in the JSONL as full ``type: user`` entries — for
example::

[monitor] alice idle
[clmail] You've got a new mail (#3017)

These are not human-typed prompts: they're system-internal signals
that pollute the rendered transcript (one full ``## 🤷 User`` heading
per signal). The renderer detects them via the bracketed-prefix
pattern, wraps them in this typed content, renders a compact
inline marker at ``DetailLevel.FULL``, and drops them entirely at
``HIGH`` and below (matching the existing "no system/hook noise"
semantics already applied to ``HookSummaryMessage`` /
``HookAttachmentMessage``).
"""

source: str # e.g. "monitor", "clmail"
text: str # The full notification line, e.g. "alice idle"

@property
def message_type(self) -> str:
return "user"


@dataclass
class IdeOpenedFile:
"""IDE notification for an opened file."""
Expand Down
11 changes: 11 additions & 0 deletions claude_code_log/renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
ToolResultMessage,
ToolUseMessage,
UnknownMessage,
UserHookNotificationMessage,
UserMemoryMessage,
UserSlashCommandMessage,
UserSteeringMessage,
Expand Down Expand Up @@ -3166,6 +3167,7 @@ def _filter_messages(messages: list[TranscriptEntry]) -> list[TranscriptEntry]:
SystemMessage,
HookSummaryMessage,
HookAttachmentMessage,
UserHookNotificationMessage,
UnknownMessage,
)

Expand Down Expand Up @@ -4253,6 +4255,15 @@ def title_UserMemoryMessage(
) -> str:
return "Memory"

def title_UserHookNotificationMessage(
self,
_content: UserHookNotificationMessage,
_message: TemplateMessage,
) -> str:
# Empty title — the body formatter already prints a compact
# ``[source] text`` marker; a heading would defeat the purpose.
return ""

def title_UserSlashCommandMessage(
self, _content: UserSlashCommandMessage, _: TemplateMessage
) -> str:
Expand Down
Loading
Loading