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. + + +