diff --git a/bun.lock b/bun.lock
index 9099c73..3143a5b 100644
--- a/bun.lock
+++ b/bun.lock
@@ -5,7 +5,7 @@
"": {
"name": "@letta-ai/letta-code",
"dependencies": {
- "@letta-ai/letta-client": "^1.7.6",
+ "@letta-ai/letta-client": "^1.7.7",
"glob": "^13.0.0",
"ink-link": "^5.0.0",
"open": "^10.2.0",
@@ -91,7 +91,7 @@
"@isaacs/brace-expansion": ["@isaacs/brace-expansion@5.0.0", "", { "dependencies": { "@isaacs/balanced-match": "^4.0.1" } }, "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA=="],
- "@letta-ai/letta-client": ["@letta-ai/letta-client@1.7.6", "", {}, "sha512-C/f03uE3TJdgfHk/8rRBxzWvY0YHCYAlrePHcTd0CRHMo++0TA1OTcgiCF+EFVDVYGzfPSeMpqgAZTNvD9r9GQ=="],
+ "@letta-ai/letta-client": ["@letta-ai/letta-client@1.7.7", "", {}, "sha512-jPq/hpctvN7DGnOg+1yRWAMdPhWeVxh89qOhQMzhO55q6/ai/yXHJ0hoXiPbQOZWHb2kO+WwjsLROVkb5Hj8ow=="],
"@types/bun": ["@types/bun@1.3.7", "", { "dependencies": { "bun-types": "1.3.7" } }, "sha512-lmNuMda+Z9b7tmhA0tohwy8ZWFSnmQm1UDWXtH5r9F7wZCfkeO3Jx7wKQ1EOiKq43yHts7ky6r8SDJQWRNupkA=="],
diff --git a/package.json b/package.json
index fea3f6f..cb1f6be 100644
--- a/package.json
+++ b/package.json
@@ -30,7 +30,7 @@
"access": "public"
},
"dependencies": {
- "@letta-ai/letta-client": "^1.7.6",
+ "@letta-ai/letta-client": "^1.7.7",
"glob": "^13.0.0",
"ink-link": "^5.0.0",
"open": "^10.2.0",
diff --git a/src/agent/check-approval.ts b/src/agent/check-approval.ts
index 4965127..311ca85 100644
--- a/src/agent/check-approval.ts
+++ b/src/agent/check-approval.ts
@@ -159,7 +159,10 @@ export async function getResumeData(
try {
const backfill = await client.conversations.messages.list(
conversationId,
- { limit: MESSAGE_HISTORY_LIMIT, order: "desc" },
+ {
+ limit: MESSAGE_HISTORY_LIMIT,
+ order: "desc",
+ },
);
return {
pendingApproval: null,
diff --git a/src/agent/message.ts b/src/agent/message.ts
index 6b7bb51..0385588 100644
--- a/src/agent/message.ts
+++ b/src/agent/message.ts
@@ -72,6 +72,7 @@ export async function sendMessageStream(
stream_tokens: opts.streamTokens ?? true,
background: opts.background ?? true,
client_tools: getClientToolsFromRegistry(),
+ include_compaction_messages: true,
},
requestOptions,
);
@@ -81,10 +82,11 @@ export async function sendMessageStream(
conversationId,
{
messages: messages,
- stream: true,
+ streaming: true,
stream_tokens: opts.streamTokens ?? true,
background: opts.background ?? true,
client_tools: getClientToolsFromRegistry(),
+ include_compaction_messages: true,
},
requestOptions,
);
diff --git a/src/cli/App.tsx b/src/cli/App.tsx
index 6d6cb0e..70b681f 100644
--- a/src/cli/App.tsx
+++ b/src/cli/App.tsx
@@ -117,6 +117,7 @@ import { ConversationSelector } from "./components/ConversationSelector";
import { colors } from "./components/colors";
// EnterPlanModeDialog removed - now using InlineEnterPlanModeApproval
import { ErrorMessage } from "./components/ErrorMessageRich";
+import { EventMessage } from "./components/EventMessage";
import { FeedbackDialog } from "./components/FeedbackDialog";
import { HelpDialog } from "./components/HelpDialog";
import { HooksManager } from "./components/HooksManager";
@@ -1473,6 +1474,12 @@ export default function App({
newlyCommitted.push({ ...ln });
continue;
}
+ // Events only commit when finished (they have running/finished phases)
+ if (ln.kind === "event" && ln.phase === "finished") {
+ emittedIdsRef.current.add(id);
+ newlyCommitted.push({ ...ln });
+ continue;
+ }
// Commands with phase should only commit when finished
if (ln.kind === "command" || ln.kind === "bash_command") {
if (!ln.phase || ln.phase === "finished") {
@@ -9394,6 +9401,10 @@ Plan file path: ${planFilePath}`;
// Always show other tool calls in progress
return ln.phase !== "finished";
}
+ // Events (like compaction) show while running
+ if (ln.kind === "event") {
+ return ln.phase === "running";
+ }
if (!tokenStreamingEnabled && ln.phase === "streaming") return false;
return ln.phase === "streaming";
});
@@ -9577,6 +9588,8 @@ Plan file path: ${planFilePath}`;
) : item.kind === "status" ? (
+ ) : item.kind === "event" ? (
+
) : item.kind === "separator" ? (
{"─".repeat(columns)}
@@ -9722,6 +9735,8 @@ Plan file path: ${planFilePath}`;
) : ln.kind === "status" ? (
+ ) : ln.kind === "event" ? (
+
) : ln.kind === "command" ? (
) : ln.kind === "bash_command" ? (
diff --git a/src/cli/components/CompactingAnimation.tsx b/src/cli/components/CompactingAnimation.tsx
new file mode 100644
index 0000000..df7af0e
--- /dev/null
+++ b/src/cli/components/CompactingAnimation.tsx
@@ -0,0 +1,174 @@
+import { Text } from "ink";
+import { memo, useEffect, useState } from "react";
+
+// Default configuration
+const DEFAULT_GARBAGE_CHARS = "._";
+const DEFAULT_TICK_MS = 30;
+const DEFAULT_MIN_GARBAGE = 1;
+const DEFAULT_MAX_GARBAGE = 2;
+const DEFAULT_CURSOR = "█";
+
+// Generate random garbage string
+function generateGarbage(count: number, chars: string): string {
+ let result = "";
+ for (let i = 0; i < count; i++) {
+ result += chars[Math.floor(Math.random() * chars.length)];
+ }
+ return result;
+}
+
+export interface FanOutAnimationOptions {
+ /** Characters to use for garbage/noise before revealing real chars */
+ garbageChars?: string;
+ /** Milliseconds between animation frames */
+ tickMs?: number;
+ /** Minimum garbage characters before each reveal (default 1) */
+ minGarbage?: number;
+ /** Maximum garbage characters before each reveal (default 2) */
+ maxGarbage?: number;
+ /** Cursor character shown at the end (default █) */
+ cursor?: string;
+ /** Whether to show cursor after animation completes */
+ showCursorOnComplete?: boolean;
+}
+
+export interface FanOutAnimationProps extends FanOutAnimationOptions {
+ /** The text to animate */
+ text: string;
+ /** Called when animation completes */
+ onComplete?: () => void;
+ /** Text styling */
+ bold?: boolean;
+ dimColor?: boolean;
+}
+
+/**
+ * Pre-generate all animation frames at initialization.
+ * Follows 3-state cycle:
+ * 1. Cursor flush against revealed text (no garbage)
+ * 2. Garbage characters appear
+ * 3. Garbage replaced with same number of real characters
+ */
+function generateFrames(
+ text: string,
+ garbageChars: string,
+ minGarbage: number,
+ maxGarbage: number,
+ cursor: string,
+): string[] {
+ const frames: string[] = [];
+ let position = 0;
+
+ // State 1: Initial frame - just cursor
+ frames.push(cursor);
+
+ while (position < text.length) {
+ const remaining = text.length - position;
+ const range = maxGarbage - minGarbage + 1;
+ const count = Math.min(
+ Math.floor(Math.random() * range) + minGarbage,
+ remaining,
+ );
+
+ // State 2: Garbage appears
+ const revealed = text.slice(0, position);
+ const garbage = generateGarbage(count, garbageChars);
+ frames.push(`${revealed}${garbage}${cursor}`);
+
+ // State 3: Garbage replaced with real chars (same count)
+ position += count;
+ const newRevealed = text.slice(0, position);
+ frames.push(`${newRevealed}${cursor}`);
+ }
+
+ // Final frame: complete text without cursor
+ frames.push(text);
+
+ return frames;
+}
+
+/**
+ * Hook for fan-out animation logic.
+ * Pre-computes all frames, then cycles through with a simple index.
+ */
+export function useFanOutAnimation(
+ text: string,
+ options: FanOutAnimationOptions = {},
+ onComplete?: () => void,
+): { display: string; isComplete: boolean } {
+ const {
+ garbageChars = DEFAULT_GARBAGE_CHARS,
+ tickMs = DEFAULT_TICK_MS,
+ minGarbage = DEFAULT_MIN_GARBAGE,
+ maxGarbage = DEFAULT_MAX_GARBAGE,
+ cursor = DEFAULT_CURSOR,
+ showCursorOnComplete = false,
+ } = options;
+
+ // Pre-generate frames once on mount
+ const [frames] = useState(() =>
+ generateFrames(text, garbageChars, minGarbage, maxGarbage, cursor),
+ );
+
+ // Simple index state - just increment each tick
+ const [frameIndex, setFrameIndex] = useState(0);
+
+ const isComplete = frameIndex >= frames.length - 1;
+
+ useEffect(() => {
+ if (isComplete) {
+ onComplete?.();
+ return;
+ }
+
+ const timer = setInterval(() => {
+ setFrameIndex((prev) => Math.min(prev + 1, frames.length - 1));
+ }, tickMs);
+
+ return () => clearInterval(timer);
+ }, [isComplete, frames.length, tickMs, onComplete]);
+
+ const display = frames[frameIndex] ?? text;
+ const finalDisplay =
+ isComplete && !showCursorOnComplete
+ ? text
+ : isComplete && showCursorOnComplete
+ ? `${text}${cursor}`
+ : display;
+
+ return { display: finalDisplay, isComplete };
+}
+
+/**
+ * Generic fan-out animation component.
+ * Characters reveal left-to-right with random garbage chars before each reveal.
+ */
+export const FanOutAnimation = memo(
+ ({
+ text,
+ onComplete,
+ bold = false,
+ dimColor = false,
+ ...options
+ }: FanOutAnimationProps) => {
+ const { display } = useFanOutAnimation(text, options, onComplete);
+
+ return (
+
+ {display}
+
+ );
+ },
+);
+
+FanOutAnimation.displayName = "FanOutAnimation";
+
+/**
+ * Animated "Compacting..." text with cursor block effect.
+ * Convenience wrapper around FanOutAnimation.
+ */
+export const CompactingAnimation = memo(() => {
+ return ;
+});
+
+CompactingAnimation.displayName = "CompactingAnimation";
diff --git a/src/cli/components/EventMessage.tsx b/src/cli/components/EventMessage.tsx
new file mode 100644
index 0000000..d677f42
--- /dev/null
+++ b/src/cli/components/EventMessage.tsx
@@ -0,0 +1,122 @@
+import { Box, Text } from "ink";
+import { memo } from "react";
+import { COMPACTION_SUMMARY_HEADER } from "../../constants";
+import { useTerminalWidth } from "../hooks/useTerminalWidth";
+import { BlinkDot } from "./BlinkDot.js";
+import { CompactingAnimation } from "./CompactingAnimation";
+import { colors } from "./colors.js";
+
+type EventLine = {
+ kind: "event";
+ id: string;
+ eventType: string;
+ eventData: Record;
+ phase: "running" | "finished";
+ summary?: string;
+ stats?: {
+ trigger?: string;
+ contextTokensBefore?: number;
+ contextTokensAfter?: number;
+ contextWindow?: number;
+ messagesCountBefore?: number;
+ messagesCountAfter?: number;
+ };
+};
+
+/**
+ * EventMessage - Displays compaction events like a tool call
+ *
+ * When running: Shows blinking dot with "Compacting..."
+ * When finished: Shows completed dot with summary
+ */
+export const EventMessage = memo(({ line }: { line: EventLine }) => {
+ const columns = useTerminalWidth();
+ const rightWidth = Math.max(0, columns - 2);
+
+ // Only handle compaction events for now
+ if (line.eventType !== "compaction") {
+ return (
+
+
+ ◆
+
+
+ Event: {line.eventType}
+
+
+ );
+ }
+
+ const isRunning = line.phase === "running";
+
+ // Dot indicator based on phase
+ const dotElement = isRunning ? (
+
+ ) : (
+ ●
+ );
+
+ // Format the args display (message count or fallback)
+ const formatArgs = (): string => {
+ const stats = line.stats;
+ if (
+ stats?.messagesCountBefore !== undefined &&
+ stats?.messagesCountAfter !== undefined
+ ) {
+ return `${stats.messagesCountBefore} → ${stats.messagesCountAfter} messages`;
+ }
+ return "...";
+ };
+
+ const argsDisplay = formatArgs();
+
+ return (
+
+ {/* Main tool call line */}
+
+
+ {dotElement}
+
+
+ {isRunning ? (
+
+ ) : (
+ Compact({argsDisplay})
+ )}
+
+
+
+ {/* Result section (only when finished) - matches CollapsedOutputDisplay format */}
+ {!isRunning && line.summary && (
+ <>
+ {/* Header line with L-bracket */}
+
+
+ {" ⎿ "}
+
+
+ {COMPACTION_SUMMARY_HEADER}
+
+
+ {/* Empty line for separation */}
+
+
+
+ {/* Summary text - indented with 5 spaces to align */}
+
+
+ {" "}
+
+
+
+ {line.summary}
+
+
+
+ >
+ )}
+
+ );
+});
+
+EventMessage.displayName = "EventMessage";
diff --git a/src/cli/helpers/accumulator.ts b/src/cli/helpers/accumulator.ts
index ecc0214..ebb86c2 100644
--- a/src/cli/helpers/accumulator.ts
+++ b/src/cli/helpers/accumulator.ts
@@ -5,10 +5,7 @@
// - Exposes `onChunk` to feed SDK events and `toLines` to render.
import type { LettaStreamingResponse } from "@letta-ai/letta-client/resources/agents/messages";
-import {
- COMPACTION_SUMMARY_HEADER,
- INTERRUPTED_BY_USER,
-} from "../../constants";
+import { INTERRUPTED_BY_USER } from "../../constants";
import { runPostToolUseHooks, runPreToolUseHooks } from "../../hooks";
import { extractCompactionSummary } from "./backfill";
import { findLastSafeSplitPoint } from "./markdownSplit";
@@ -136,6 +133,23 @@ export type Line =
streaming?: StreamingState;
}
| { kind: "error"; id: string; text: string }
+ | {
+ kind: "event";
+ id: string;
+ eventType: string;
+ eventData: Record;
+ // Compaction events have additional fields populated when summary_message arrives
+ phase: "running" | "finished";
+ summary?: string;
+ stats?: {
+ trigger?: string;
+ contextTokensBefore?: number;
+ contextTokensAfter?: number;
+ contextWindow?: number;
+ messagesCountBefore?: number;
+ messagesCountAfter?: number;
+ };
+ }
| {
kind: "command";
id: string;
@@ -522,14 +536,17 @@ export function onChunk(b: Buffers, chunk: LettaStreamingResponse) {
const rawText = extractTextPart(chunk.content);
if (!rawText) break;
- // Check if this is a compaction summary message
+ // Check if this is a compaction summary message (old format embedded in user_message)
const compactionSummary = extractCompactionSummary(rawText);
if (compactionSummary) {
- // Render as a user message with context header and summary
+ // Render as a finished compaction event
ensure(b, id, () => ({
- kind: "user",
+ kind: "event",
id,
- text: `${COMPACTION_SUMMARY_HEADER}\n\n${compactionSummary}`,
+ eventType: "compaction",
+ eventData: {},
+ phase: "finished",
+ summary: compactionSummary,
}));
}
// If not a summary, ignore it (user messages aren't rendered during streaming)
@@ -804,8 +821,80 @@ export function onChunk(b: Buffers, chunk: LettaStreamingResponse) {
break;
}
- default:
- break; // ignore ping/etc
+ default: {
+ // Handle new compaction message types (when include_compaction_messages=true)
+ // These are not yet in the SDK types, so we handle them via string comparison
+ const msgType = chunk.message_type as string | undefined;
+
+ if (msgType === "summary_message") {
+ // Use otid if available, fall back to id
+ const summaryChunk = chunk as LettaStreamingResponse & {
+ id?: string;
+ otid?: string;
+ summary?: string;
+ compaction_stats?: {
+ trigger?: string;
+ context_tokens_before?: number;
+ context_tokens_after?: number;
+ context_window?: number;
+ messages_count_before?: number;
+ messages_count_after?: number;
+ };
+ };
+ const summaryText = summaryChunk.summary || "";
+ const stats = summaryChunk.compaction_stats;
+
+ // Find the most recent compaction event line and update it with summary and stats
+ for (let i = b.order.length - 1; i >= 0; i--) {
+ const orderId = b.order[i];
+ if (!orderId) continue;
+ const line = b.byId.get(orderId);
+ if (line?.kind === "event" && line.eventType === "compaction") {
+ line.phase = "finished";
+ line.summary = summaryText;
+ if (stats) {
+ line.stats = {
+ trigger: stats.trigger,
+ contextTokensBefore: stats.context_tokens_before,
+ contextTokensAfter: stats.context_tokens_after,
+ contextWindow: stats.context_window,
+ messagesCountBefore: stats.messages_count_before,
+ messagesCountAfter: stats.messages_count_after,
+ };
+ }
+ break;
+ }
+ }
+ break;
+ }
+
+ if (msgType === "event_message") {
+ // Use otid if available, fall back to id
+ const eventChunk = chunk as LettaStreamingResponse & {
+ id?: string;
+ otid?: string;
+ event_type?: string;
+ event_data?: Record;
+ };
+ const id = eventChunk.otid || eventChunk.id;
+ if (!id) break;
+
+ // Handle otid transition (mark previous line as finished)
+ handleOtidTransition(b, id);
+
+ ensure(b, id, () => ({
+ kind: "event",
+ id,
+ eventType: eventChunk.event_type || "unknown",
+ eventData: eventChunk.event_data || {},
+ phase: "running",
+ }));
+ break;
+ }
+
+ // ignore ping/etc
+ break;
+ }
}
}
diff --git a/src/cli/helpers/backfill.ts b/src/cli/helpers/backfill.ts
index 2e55e64..0f10b55 100644
--- a/src/cli/helpers/backfill.ts
+++ b/src/cli/helpers/backfill.ts
@@ -5,11 +5,7 @@ import type {
Message,
TextContent,
} from "@letta-ai/letta-client/resources/agents/messages";
-import {
- COMPACTION_SUMMARY_HEADER,
- SYSTEM_REMINDER_CLOSE,
- SYSTEM_REMINDER_OPEN,
-} from "../../constants";
+import { SYSTEM_REMINDER_CLOSE, SYSTEM_REMINDER_OPEN } from "../../constants";
import type { Buffers } from "./accumulator";
/**
@@ -183,15 +179,18 @@ export function backfillBuffers(buffers: Buffers, history: Message[]): void {
case "user_message": {
const rawText = renderUserContentParts(msg.content);
- // Check if this is a compaction summary message (system_alert with summary)
+ // Check if this is a compaction summary message (old format embedded in user_message)
const compactionSummary = extractCompactionSummary(rawText);
if (compactionSummary) {
- // Render as a user message with context header and summary
+ // Render as a finished compaction event
const exists = buffers.byId.has(lineId);
buffers.byId.set(lineId, {
- kind: "user",
+ kind: "event",
id: lineId,
- text: `${COMPACTION_SUMMARY_HEADER}\n\n${compactionSummary}`,
+ eventType: "compaction",
+ eventData: {},
+ phase: "finished",
+ summary: compactionSummary,
});
if (!exists) buffers.order.push(lineId);
break;
@@ -336,8 +335,74 @@ export function backfillBuffers(buffers: Buffers, history: Message[]): void {
break;
}
- default:
- break; // ignore other message types
+ default: {
+ // Handle new compaction message types (when include_compaction_messages=true)
+ // These are not yet in the SDK types, so we handle them via string comparison
+ const msgType = msg.message_type as string | undefined;
+
+ if (msgType === "summary_message") {
+ // SummaryMessage has: summary (str), compaction_stats (optional)
+ const summaryMsg = msg as Message & {
+ summary?: string;
+ compaction_stats?: {
+ trigger?: string;
+ context_tokens_before?: number;
+ context_tokens_after?: number;
+ context_window?: number;
+ messages_count_before?: number;
+ messages_count_after?: number;
+ };
+ };
+
+ const summaryText = summaryMsg.summary || "";
+ const stats = summaryMsg.compaction_stats;
+
+ // Find the most recent compaction event line and update it with summary and stats
+ for (let i = buffers.order.length - 1; i >= 0; i--) {
+ const orderId = buffers.order[i];
+ if (!orderId) continue;
+ const line = buffers.byId.get(orderId);
+ if (line?.kind === "event" && line.eventType === "compaction") {
+ line.phase = "finished";
+ line.summary = summaryText;
+ if (stats) {
+ line.stats = {
+ trigger: stats.trigger,
+ contextTokensBefore: stats.context_tokens_before,
+ contextTokensAfter: stats.context_tokens_after,
+ contextWindow: stats.context_window,
+ messagesCountBefore: stats.messages_count_before,
+ messagesCountAfter: stats.messages_count_after,
+ };
+ }
+ break;
+ }
+ }
+ break;
+ }
+
+ if (msgType === "event_message") {
+ // EventMessage has: event_type (str), event_data (dict)
+ const eventMsg = msg as Message & {
+ event_type?: string;
+ event_data?: Record;
+ };
+
+ const exists = buffers.byId.has(lineId);
+ buffers.byId.set(lineId, {
+ kind: "event",
+ id: lineId,
+ eventType: eventMsg.event_type || "unknown",
+ eventData: eventMsg.event_data || {},
+ phase: "finished", // In backfill, events are always finished (summary already processed)
+ });
+ if (!exists) buffers.order.push(lineId);
+ break;
+ }
+
+ // ignore other message types
+ break;
+ }
}
}
diff --git a/src/constants.ts b/src/constants.ts
index dad6023..1d675a9 100644
--- a/src/constants.ts
+++ b/src/constants.ts
@@ -34,7 +34,7 @@ export const MEMFS_CONFLICT_CHECK_INTERVAL = 5;
* Header displayed before compaction summary when conversation context is truncated
*/
export const COMPACTION_SUMMARY_HEADER =
- "This session is being continued from a previous conversation that ran out of context. The summary below covers the earlier portion of the conversation.";
+ "(Earlier messages in this conversation have been compacted to free up context, summarized below)";
/**
* Status bar thresholds - only show indicators when values exceed these