diff --git a/client/js/app.js b/client/js/app.js index c9837a3..4723f71 100644 --- a/client/js/app.js +++ b/client/js/app.js @@ -334,7 +334,7 @@ break; case 'context_compressed': - appendCompressionNotice(messagesEl); + appendCompressionNotice(messagesEl, event.messages_before, event.messages_after, event.summary); scrollToBottom(messagesEl); break; diff --git a/client/js/chat.js b/client/js/chat.js index 6a9a818..59baf41 100644 --- a/client/js/chat.js +++ b/client/js/chat.js @@ -404,11 +404,28 @@ /** * Inline notice that compression ran — appended to the message list. */ -export function appendCompressionNotice(el) { - const div = document.createElement('div'); - div.className = 'compression-notice'; - div.textContent = '↑ Older messages summarized to free context space'; - el.appendChild(div); +export function appendCompressionNotice(el, before, after, summary) { + const label = (before != null && after != null) + ? `↑ Context compressed: ${before} → ${after} messages` + : '↑ Older messages summarized to free context space'; + + if (summary) { + const details = document.createElement('details'); + details.className = 'compression-notice compression-notice--expandable'; + const sumEl = document.createElement('summary'); + sumEl.textContent = label; + details.appendChild(sumEl); + const body = document.createElement('div'); + body.className = 'compression-summary-body'; + body.innerHTML = marked.parse(summary); + details.appendChild(body); + el.appendChild(details); + } else { + const div = document.createElement('div'); + div.className = 'compression-notice'; + div.textContent = label; + el.appendChild(div); + } } /** diff --git a/client/style.css b/client/style.css index d99b020..28b3499 100644 --- a/client/style.css +++ b/client/style.css @@ -484,6 +484,39 @@ opacity: 0.7; } +.compression-notice--expandable { + border-radius: 8px; + padding: 6px 12px; + cursor: pointer; + opacity: 0.85; + max-width: 680px; +} + +.compression-notice--expandable > summary { + cursor: pointer; + list-style: none; + user-select: none; +} + +.compression-notice--expandable > summary::before { + content: '▶ '; + font-size: 9px; +} + +.compression-notice--expandable[open] > summary::before { + content: '▼ '; +} + +.compression-summary-body { + margin-top: 8px; + padding-top: 8px; + border-top: 1px solid #30363d; + font-size: 12px; + line-height: 1.5; + color: var(--text-secondary); + white-space: normal; +} + /* ── Agent note — model text between tool calls ──────── */ .agent-note { diff --git a/navi/api/websocket.py b/navi/api/websocket.py index 4a2c103..67a666a 100644 --- a/navi/api/websocket.py +++ b/navi/api/websocket.py @@ -98,6 +98,7 @@ "type": "context_compressed", "messages_before": event.messages_before, "messages_after": event.messages_after, + "summary": event.summary, } if isinstance(event, TurnThinking): return {"type": "turn_thinking", "thinking": event.thinking, "is_subagent": event.is_subagent} diff --git a/navi/core/compressor.py b/navi/core/compressor.py index 0276dff..9560003 100644 --- a/navi/core/compressor.py +++ b/navi/core/compressor.py @@ -128,7 +128,7 @@ model: str, temperature: float, keep_recent: int, -) -> list[Message] | None: +) -> tuple[list[Message], str] | None: """ Summarize old messages in the LLM context and return a shorter context list. Only operates on `context` — the full display history (session.messages) is never touched. @@ -171,4 +171,4 @@ created_at=datetime.now(timezone.utc), ) - return system_msgs + [summary_msg] + to_keep + return system_msgs + [summary_msg] + to_keep, summary_text diff --git a/navi/core/events.py b/navi/core/events.py index 4f994a1..358c688 100644 --- a/navi/core/events.py +++ b/navi/core/events.py @@ -62,6 +62,7 @@ messages_before: int messages_after: int + summary: str = "" # the actual summary text produced by the LLM @dataclass diff --git a/navi/workers/compressor.py b/navi/workers/compressor.py index bb23249..2eb2845 100644 --- a/navi/workers/compressor.py +++ b/navi/workers/compressor.py @@ -28,7 +28,7 @@ count_before = len(session.context) try: - new_context = await compress_context( + result = await compress_context( context=session.context, llm=ctx.llm, model=ctx.model, @@ -39,9 +39,10 @@ log.warning("compression_worker.llm_failed", session_id=ctx.session_id, exc_info=True) return WorkerResult() - if new_context is None: + if result is None: return WorkerResult() + new_context, summary_text = result session.context = new_context session.context_token_count = 0 # reset so next turn doesn't re-compress pre-call await ctx.session_store.save(session) @@ -56,4 +57,5 @@ return WorkerResult(events=[ContextCompressed( messages_before=count_before, messages_after=len(session.context), + summary=summary_text, )])