diff --git a/apps/desktop/src/components/chat/context-meter.tsx b/apps/desktop/src/components/chat/context-meter.tsx
index 9571933f..7b093e06 100644
--- a/apps/desktop/src/components/chat/context-meter.tsx
+++ b/apps/desktop/src/components/chat/context-meter.tsx
@@ -1,43 +1,18 @@
import { Link } from "react-router";
import { motion } from "motion/react";
import { ArrowRight, FoldVertical, Loader2, X } from "lucide-react";
-import type { CompactInfo, ConversationContext } from "@godmode/shared";
-import { COMPRESS_MIN_TOKENS, DEFAULT_AUTO_COMPACT_PERCENT } from "@godmode/shared";
+import type { CompactInfo, ContextGauge, ConversationContext } from "@godmode/shared";
+import { contextGauge, tokensLabel } from "@godmode/shared";
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useSettings } from "@/lib/hooks";
import { cn } from "@/lib/utils";
-/** Claude Code summarizes by itself close to a full window when Godmode sets no threshold. */
-const CLAUDE_DEFAULT_SHARE = 0.9;
-
-export function tokensLabel(n: number): string {
- if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(n % 1_000_000 === 0 ? 0 : 1)}M`;
- if (n >= 1_000) return `${(n / 1_000).toFixed(n >= 100_000 ? 0 : 1)}k`;
- return String(n);
-}
-
-export interface ContextGauge {
- tokens: number;
- window: number | null;
- /** Share of the window in use, 0..1; null without a window. */
- share: number | null;
- /** Where Claude summarizes by itself (share of the window); null = never. */
- autoAt: number | null;
- tone: "calm" | "warn" | "full";
- /** Worth a summary at all. */
- compressible: boolean;
-}
+export { tokensLabel, type ContextGauge };
export function useContextGauge(context: ConversationContext | null | undefined): ContextGauge | null {
- const pct = useSettings().data?.runner.autoCompactPercent;
- if (!context) return null;
- const autoAt = pct === 0 ? null : pct === null ? CLAUDE_DEFAULT_SHARE : (pct ?? DEFAULT_AUTO_COMPACT_PERCENT) / 100;
- const share = context.window ? Math.min(1, context.tokens / context.window) : null;
- const limit = autoAt ?? CLAUDE_DEFAULT_SHARE;
- const tone = share === null ? "calm" : share >= limit * 0.95 ? "full" : share >= limit * 0.75 ? "warn" : "calm";
- return { tokens: context.tokens, window: context.window, share, autoAt, tone, compressible: context.tokens >= COMPRESS_MIN_TOKENS };
+ return contextGauge(context, useSettings().data?.runner.autoCompactPercent);
}
const TONE = {
diff --git a/apps/desktop/src/components/settings/ai-section.tsx b/apps/desktop/src/components/settings/ai-section.tsx
index 26a02948..92c73f0f 100644
--- a/apps/desktop/src/components/settings/ai-section.tsx
+++ b/apps/desktop/src/components/settings/ai-section.tsx
@@ -266,7 +266,7 @@ export function AiSection({ settings }: { settings: Settings }) {
- {[30, 40, 50, 60, 70, 80].map((p) => (
+ {[40, 50, 60, 70, 80].map((p) => (
At {p}%{p === DEFAULT_AUTO_COMPACT_PERCENT ? " (default)" : ""}
diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx
index 3a76d0c8..0e3ba5e1 100644
--- a/apps/mobile/src/app/_layout.tsx
+++ b/apps/mobile/src/app/_layout.tsx
@@ -102,6 +102,7 @@ export default function RootLayout() {
+
diff --git a/apps/mobile/src/app/chat-memory.tsx b/apps/mobile/src/app/chat-memory.tsx
new file mode 100644
index 00000000..6d339006
--- /dev/null
+++ b/apps/mobile/src/app/chat-memory.tsx
@@ -0,0 +1,125 @@
+import { useQuery } from "@tanstack/react-query";
+import { router, useLocalSearchParams } from "expo-router";
+import { useState } from "react";
+import { Alert, Pressable, ScrollView, StyleSheet, View } from "react-native";
+import { tokensLabel } from "@godmode/shared";
+import { Icon } from "@/components/icon";
+import { toneColor } from "@/components/context-ring";
+import { Button, Card, T, tap } from "@/components/ui";
+import { errorText } from "@/lib/api";
+import { fetchConversation } from "@/lib/chat-cache";
+import { compressChat, isCompressCommand, useContextGauge } from "@/lib/chat-memory";
+import { useAgents } from "@/lib/hooks";
+import { useChatRun } from "@/lib/live";
+import { qk } from "@/lib/query";
+import { radius, space, useColors } from "@/lib/theme";
+
+/** How full a chat's memory is, and Compress: Claude Code turns the conversation so far into a summary. */
+export default function ChatMemorySheet() {
+ const c = useColors();
+ const { conversationId = "" } = useLocalSearchParams<{ conversationId: string }>();
+ const conversation = useQuery({ queryKey: qk.conversation(conversationId), queryFn: () => fetchConversation(conversationId), enabled: !!conversationId });
+ const { byId } = useAgents();
+ const agentName = byId.get(conversation.data?.agentId ?? "")?.name ?? "The agent";
+ const run = useChatRun(conversationId);
+ const gauge = useContextGauge(conversation.data?.context);
+ const [busy, setBusy] = useState(false);
+ const queued = conversation.data?.queue.some((m) => isCompressCommand(m.content)) ?? false;
+
+ const compress = async () => {
+ setBusy(true);
+ try {
+ await compressChat(conversationId);
+ router.back();
+ } catch (err) {
+ Alert.alert("Couldn't compress the chat", errorText(err));
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ const pct = gauge?.share != null ? Math.round(gauge.share * 100) : null;
+ const color = gauge ? toneColor(c, gauge.tone) : c.textMuted;
+
+ return (
+
+
+ Chat memory
+
+ What {agentName} carries along with every step of this chat.
+
+
+
+
+ {gauge ? (
+ <>
+
+
+
+ {tokensLabel(gauge.tokens)}
+
+
+ {gauge.window ? `of ${tokensLabel(gauge.window)} tokens` : "tokens"}
+
+
+ {pct !== null ? (
+
+ {pct}%
+
+ ) : null}
+
+ {gauge.share !== null ? (
+
+
+ {gauge.autoAt !== null ? : null}
+
+ ) : null}
+
+ {gauge.autoAt === null ? "Auto-compress is off." : `Compresses by itself at ${Math.round(gauge.autoAt * 100)}%.`}
+
+ >
+ ) : (
+
+ Known after {agentName}'s next answer in this chat.
+
+ )}
+
+
+
+ Compress turns the conversation so far into a short summary. {agentName} keeps the gist, your instructions and its files, and every next step gets faster and cheaper.
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ content: { padding: space.xl, paddingBottom: 48, gap: space.lg },
+ row: { flexDirection: "row", alignItems: "flex-end", justifyContent: "space-between", gap: space.md },
+ amount: { flexDirection: "row", alignItems: "baseline", gap: 6 },
+ track: { height: 6, borderRadius: radius.pill, overflow: "visible", position: "relative" },
+ fill: { height: 6, borderRadius: radius.pill },
+ tick: { position: "absolute", top: -4, width: 1.5, height: 14, borderRadius: 1 },
+ link: { flexDirection: "row", alignItems: "center", paddingVertical: space.sm },
+});
diff --git a/apps/mobile/src/app/chat/[id].tsx b/apps/mobile/src/app/chat/[id].tsx
index 40468dd2..160b7438 100644
--- a/apps/mobile/src/app/chat/[id].tsx
+++ b/apps/mobile/src/app/chat/[id].tsx
@@ -27,6 +27,7 @@ import { LiveStrip } from "@/components/live-strip";
import { AssistantMessage, UserMessage } from "@/components/message";
import { Icon } from "@/components/icon";
import { ModelButton } from "@/components/model-button";
+import { ContextRing } from "@/components/context-ring";
import { ChatScope, type ChatScopeValue } from "@/components/question-card";
import { QueueTray } from "@/components/queue-tray";
import { Button, ErrorState, Skeleton, T, tap } from "@/components/ui";
@@ -34,6 +35,7 @@ import { openHumanTask } from "@/components/human-task-row";
import { ApiError, api, errorText } from "@/lib/api";
import { encodeFiles, type PendingFile } from "@/lib/attachments";
import { fetchConversation, loadEarlier } from "@/lib/chat-cache";
+import { isCompressCommand, useContextGauge } from "@/lib/chat-memory";
import { setQueue } from "@/lib/composer";
import { whenText } from "@/lib/format";
import { newQueueId, pendingQueued, withPending } from "@/lib/pending-queue";
@@ -101,6 +103,7 @@ export default function Chat() {
const { byId } = useAgents();
const agent = conversation.data ? byId.get(conversation.data.agentId) : undefined;
const run = useChatRun(id);
+ const activity = useRunActivity(run?.id);
// Only which message streams: its text lives in the row that shows it, so the screen doesn't render with every word.
const liveMessageId = useLive((s) => (run && s.drafts[id]?.runId === run.id ? s.drafts[id]!.messageId : null));
const screens = useChatScreens(conversation.data, agent);
@@ -145,6 +148,12 @@ export default function Chat() {
// While the agent works, is paused or older messages still wait, a new message joins the queue.
const queueing = !waiting && (!!run || !!paused || queue.length > 0);
const followup = !run && !paused ? (conversation.data?.followup ?? null) : null;
+ // Chat memory: the ring next to the model, and a strip once the chat gets long (hidden until it grows further).
+ const gauge = useContextGauge(conversation.data?.context);
+ const compressing = (!!run && (activity ?? "").startsWith("Compressing")) || queue.some((m) => isCompressCommand(m.content));
+ const [compressHiddenAt, setCompressHiddenAt] = useState(null);
+ const longChat =
+ !!gauge && gauge.tone !== "calm" && gauge.compressible && !compressing && (compressHiddenAt === null || gauge.tokens > compressHiddenAt * 1.2) ? gauge : null;
const scope = useMemo(
() => ({ conversationId: id, agentName: agent?.name ?? "The agent", openQuestionId: (pause?.reason === "question" && pause.question?.id) || null }),
@@ -371,6 +380,8 @@ export default function Chat() {
) : followup ? (
+ ) : longChat ? (
+ void send("/compress", [])} onHide={() => setCompressHiddenAt(longChat.tokens)} />
) : null}
+ {gauge ? : null}
+ >
) : null
}
/>
@@ -664,6 +678,27 @@ function endedTurn(conv: ConversationWithMessages): { runId: string; mode: Retry
return { runId: last.runId, mode: retryModeOf(last.blocks) === "continue" && conv.claudeSessionId ? "continue" : "again" };
}
+/** Above the composer once the chat gets long. */
+function LongChatStrip({ pct, full, onCompress, onHide }: { pct: number | null; full: boolean; onCompress: () => void; onHide: () => void }) {
+ const c = useColors();
+ return (
+
+
+
+ This chat is getting long{pct !== null ? ` · ${pct}%` : ""}
+
+ { tap(); onCompress(); }} hitSlop={10} accessibilityRole="button" accessibilityLabel="Compress the chat">
+
+ Compress
+
+
+
+
+
+
+ );
+}
+
/** Above the composer when the last turn ended early. */
function EndedStrip({ mode, onRetry, busy }: { mode: RetryMode; onRetry: () => void; busy: boolean }) {
const c = useColors();
diff --git a/apps/mobile/src/app/settings/ai.tsx b/apps/mobile/src/app/settings/ai.tsx
index 8b3110d4..bdc0b38a 100644
--- a/apps/mobile/src/app/settings/ai.tsx
+++ b/apps/mobile/src/app/settings/ai.tsx
@@ -1,6 +1,6 @@
import { Stack } from "expo-router";
import { ActivityIndicator, ScrollView, StyleSheet, View } from "react-native";
-import { ULTRACODE_HINT, effortForModel, findModel, type Effort } from "@godmode/shared";
+import { DEFAULT_AUTO_COMPACT_PERCENT, ULTRACODE_HINT, effortForModel, findModel, type Effort } from "@godmode/shared";
import { CloseButton } from "@/components/close-button";
import { FieldRow, Group, PickerRow, SegmentRow, StepperRow, SwitchRow } from "@/components/form";
import { useModelCatalog } from "@/lib/composer";
@@ -119,6 +119,20 @@ export default function ModelAndLimits() {
/>
+
+ ({ value: String(p), label: `At ${p}%${p === DEFAULT_AUTO_COMPACT_PERCENT ? " (default)" : ""}` })),
+ { value: "claude", label: "When nearly full" },
+ { value: "0", label: "Never by itself" },
+ ]}
+ onChange={(v) => void patchSettings({ runner: { autoCompactPercent: v === "claude" ? null : Number(v) } })}
+ />
+
+
+
+
+
+ );
+}
+
+/** How full the chat's memory is, in the composer's toolbar; opens the Chat memory sheet. */
+export function ContextRing({ gauge, conversationId }: { gauge: ContextGauge; conversationId: string }) {
+ const c = useColors();
+ const pct = gauge.share === null ? null : Math.round(gauge.share * 100);
+ const color = toneColor(c, gauge.tone);
+ return (
+ {
+ tap();
+ router.push({ pathname: "/chat-memory", params: { conversationId } });
+ }}
+ style={({ pressed }) => [styles.button, { opacity: pressed ? 0.6 : 1 }]}
+ >
+
+ {gauge.tone !== "calm" && pct !== null ? (
+
+ {pct}%
+
+ ) : null}
+
+ );
+}
+
+const styles = StyleSheet.create({
+ button: { flexDirection: "row", alignItems: "center", gap: 5, height: 36, paddingHorizontal: 6 },
+});
diff --git a/apps/mobile/src/components/message.tsx b/apps/mobile/src/components/message.tsx
index 9f5c12ff..e582ef3a 100644
--- a/apps/mobile/src/components/message.tsx
+++ b/apps/mobile/src/components/message.tsx
@@ -5,8 +5,8 @@ import { format } from "date-fns";
import { memo, useEffect, useMemo } from "react";
import { LayoutAnimation, Pressable, StyleSheet, View } from "react-native";
import Animated, { FadeIn, useAnimatedStyle, useSharedValue, withRepeat, withSequence, withTiming } from "react-native-reanimated";
-import type { Attachment, Message, MessageBlock, RetryCause, RetryMode } from "@godmode/shared";
-import { isPlaceholderReply, toolActivity } from "@godmode/shared";
+import type { Attachment, CompactInfo, Message, MessageBlock, RetryCause, RetryMode } from "@godmode/shared";
+import { isPlaceholderReply, tokensLabel, toolActivity } from "@godmode/shared";
import { FileChip } from "./attachments";
import { CALL_STATUS, callName, openCall } from "./call-parts";
import { Icon, type IconName } from "./icon";
@@ -64,6 +64,12 @@ function subagentState(block: ToolUse, streaming: boolean) {
return { task, background, status, running, failed, result };
}
+/** The compression divider already says what /compact did. */
+function withoutCompactCard(blocks: MessageBlock[]): MessageBlock[] {
+ if (!blocks.some((b) => b.type === "notice" && b.compacted)) return blocks;
+ return blocks.filter((b) => !(b.type === "command" && b.name === "compact"));
+}
+
const SOURCE_CAPTION = { automation: "Automation", delegation: "From another agent", task: "Board ticket" } as const;
export const UserMessage = memo(function UserMessage({ message }: { message: Message }) {
@@ -92,7 +98,7 @@ export const UserMessage = memo(function UserMessage({ message }: { message: Mes
export const AssistantMessage = memo(function AssistantMessage({ blocks, streaming, content }: { blocks: MessageBlock[]; streaming?: boolean; content?: string }) {
// A reply stored without blocks is its text.
- const parts = useMemo(() => group(blocks.length || !content ? blocks : [{ type: "text", text: content }]), [blocks, content]);
+ const parts = useMemo(() => group(blocks.length || !content ? withoutCompactCard(blocks) : [{ type: "text", text: content }]), [blocks, content]);
const last = parts[parts.length - 1];
return (
@@ -108,7 +114,7 @@ export const AssistantMessage = memo(function AssistantMessage({ blocks, streami
)}
{streaming ? : null}
{streaming && last?.kind === "block" && last.block.type === "text" ? : null}
- {!streaming && content?.trim() ? : null}
+ {!streaming && content?.trim() && parts.some((p) => p.kind !== "block" || p.block.type === "text") ? : null}
);
});
@@ -168,6 +174,7 @@ const Block = memo(function Block({ block, streaming }: { block: MessageBlock; s
);
case "notice":
+ if (block.compacted) return ;
return (
+
+
+
+
+
+ {info.auto ? "Compressed automatically" : "Chat compressed"}
+
+ {sizes}
+
+
+
+
+ );
+}
diff --git a/apps/mobile/src/lib/chat-memory.ts b/apps/mobile/src/lib/chat-memory.ts
new file mode 100644
index 00000000..cfa72e0a
--- /dev/null
+++ b/apps/mobile/src/lib/chat-memory.ts
@@ -0,0 +1,19 @@
+import { contextGauge, type ConversationContext, type ContextGauge } from "@godmode/shared";
+import { api } from "./api";
+import { newQueueId } from "./pending-queue";
+import { qk, queryClient } from "./query";
+import { useSettings } from "./setup";
+
+export function useContextGauge(context: ConversationContext | null | undefined): ContextGauge | null {
+ return contextGauge(context, useSettings().data?.runner.autoCompactPercent);
+}
+
+/** Sends /compress: Claude Code summarizes the chat so far (queued behind work that is running). */
+export async function compressChat(conversationId: string): Promise {
+ await api.conversations.send(conversationId, { content: "/compress", attachments: [], queueId: newQueueId() });
+ void queryClient.invalidateQueries({ queryKey: qk.conversation(conversationId) });
+}
+
+export function isCompressCommand(text: string): boolean {
+ return /^\/(compress|compact)\b/.test(text.trim());
+}
diff --git a/packages/core/src/db/migrations.ts b/packages/core/src/db/migrations.ts
index a3d2099c..a34a2088 100644
--- a/packages/core/src/db/migrations.ts
+++ b/packages/core/src/db/migrations.ts
@@ -1649,6 +1649,15 @@ CREATE TABLE replica_deferred (
-- context meter and Compress in the chat.
ALTER TABLE conversations ADD COLUMN context_tokens INTEGER;
ALTER TABLE conversations ADD COLUMN context_window INTEGER;
+`,
+ },
+ {
+ id: 101,
+ name: "auto_compact_70",
+ sql: /* sql */ `
+-- Long chats compress at 70% by default now (50% for one release): a runner section saved with the old default follows.
+UPDATE settings SET value = json_set(value, '$.autoCompactPercent', 70)
+WHERE key = 'runner' AND json_valid(value) AND json_extract(value, '$.autoCompactPercent') = 50;
`,
},
];
diff --git a/packages/core/test/slash-commands.test.ts b/packages/core/test/slash-commands.test.ts
index 503eafa3..e58599f0 100644
--- a/packages/core/test/slash-commands.test.ts
+++ b/packages/core/test/slash-commands.test.ts
@@ -121,7 +121,7 @@ describe("slash commands in chat runs", () => {
test("the chat knows how full its session is; /compress compacts it", async () => {
const big = await send(conversationId, "BIG_CONTEXT");
expect(big.conv.context).toEqual({ tokens: 421_602, window: 1_000_000 });
- expect(big.inv.env.CLAUDE_AUTOCOMPACT_PCT_OVERRIDE).toBe("50");
+ expect(big.inv.env.CLAUDE_AUTOCOMPACT_PCT_OVERRIDE).toBe("70");
const { inv, conv } = await send(conversationId, "/compress keep the open tasks");
expect(inv.prompt).toBe("/compact keep the open tasks");
expect(conv.messages.at(-2)!.content).toBe("/compress keep the open tasks");
diff --git a/packages/shared/src/chatContext.ts b/packages/shared/src/chatContext.ts
new file mode 100644
index 00000000..7f366707
--- /dev/null
+++ b/packages/shared/src/chatContext.ts
@@ -0,0 +1,36 @@
+import { COMPRESS_MIN_TOKENS, DEFAULT_AUTO_COMPACT_PERCENT } from "./constants";
+import type { ConversationContext } from "./models";
+
+/** Claude Code summarizes by itself close to a full window when Godmode sets no threshold. */
+const CLAUDE_DEFAULT_SHARE = 0.9;
+
+/** How full a chat's memory is, for the meter in the composer (app and phone). */
+export interface ContextGauge {
+ tokens: number;
+ window: number | null;
+ /** Share of the window in use, 0..1; null without a window. */
+ share: number | null;
+ /** Where Claude summarizes by itself (share of the window); null = never. */
+ autoAt: number | null;
+ tone: "calm" | "warn" | "full";
+ /** Worth a summary at all. */
+ compressible: boolean;
+}
+
+/** `autoCompactPercent` as in RunnerSettings: undefined = the default, null = Claude Code's own threshold, 0 = never. */
+export function contextGauge(context: ConversationContext | null | undefined, autoCompactPercent: number | null | undefined): ContextGauge | null {
+ if (!context) return null;
+ const pct = autoCompactPercent;
+ const autoAt = pct === 0 ? null : pct === null ? CLAUDE_DEFAULT_SHARE : (pct ?? DEFAULT_AUTO_COMPACT_PERCENT) / 100;
+ const share = context.window ? Math.min(1, context.tokens / context.window) : null;
+ const limit = autoAt ?? CLAUDE_DEFAULT_SHARE;
+ const tone = share === null ? "calm" : share >= limit * 0.95 ? "full" : share >= limit * 0.75 ? "warn" : "calm";
+ return { tokens: context.tokens, window: context.window, share, autoAt, tone, compressible: context.tokens >= COMPRESS_MIN_TOKENS };
+}
+
+/** 421602 → "422k", 1000000 → "1M". */
+export function tokensLabel(n: number): string {
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(n % 1_000_000 === 0 ? 0 : 1)}M`;
+ if (n >= 1_000) return `${(n / 1_000).toFixed(n >= 100_000 ? 0 : 1)}k`;
+ return String(n);
+}
diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts
index 013f9a38..eb30d4f9 100644
--- a/packages/shared/src/constants.ts
+++ b/packages/shared/src/constants.ts
@@ -109,7 +109,7 @@ export function effortForModel(efforts: readonly Effort[], effort: Effort | null
}
/** A chat is summarized by itself once its session fills this much of the context window (percent). */
-export const DEFAULT_AUTO_COMPACT_PERCENT = 50;
+export const DEFAULT_AUTO_COMPACT_PERCENT = 70;
/** Below this a summary saves next to nothing: Compress is not offered. */
export const COMPRESS_MIN_TOKENS = 20_000;
diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts
index 15e89f41..5ae4af1a 100644
--- a/packages/shared/src/index.ts
+++ b/packages/shared/src/index.ts
@@ -31,3 +31,4 @@ export * from "./humanTasks";
export * from "./cards";
export * from "./calls";
export * from "./devices";
+export * from "./chatContext";