paseo-smart-session 2.1.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +40 -11
  2. package/client/agents.ts +26 -4
  3. package/client/charts.tsx +277 -152
  4. package/client/chat.tsx +122 -0
  5. package/client/display.tsx +55 -33
  6. package/client/guard/index.tsx +14 -16
  7. package/client/guard/paused.tsx +125 -38
  8. package/client/guard/pill-icons.tsx +1 -1
  9. package/client/guard/pills.ts +16 -6
  10. package/client/guard/surfaces.ts +3 -2
  11. package/client/guard/timeline.tsx +116 -56
  12. package/client/kit.tsx +144 -0
  13. package/client/live.ts +49 -0
  14. package/client/motion.tsx +94 -0
  15. package/client/overview.tsx +373 -0
  16. package/client/pill-store.ts +77 -0
  17. package/client/pill.tsx +83 -260
  18. package/client/popover.tsx +113 -0
  19. package/client/settings.tsx +105 -0
  20. package/client/theme.ts +56 -0
  21. package/docs/media/smart-session-chat.png +0 -0
  22. package/docs/media/smart-session-flow.gif +0 -0
  23. package/docs/media/smart-session-guard.png +0 -0
  24. package/docs/media/smart-session-hero-light.png +0 -0
  25. package/docs/media/smart-session-hero.png +0 -0
  26. package/docs/media/smart-session-mobile.png +0 -0
  27. package/docs/media/smart-session-pill.png +0 -0
  28. package/docs/navigation-verification.md +6 -6
  29. package/docs/screenshots/display-settings-desktop.png +0 -0
  30. package/docs/screenshots/display-settings-mobile.png +0 -0
  31. package/docs/session-guard.md +3 -3
  32. package/index.client.tsx +47 -35
  33. package/index.server.ts +14 -5
  34. package/mcp.mjs +1 -1
  35. package/package.json +1 -1
  36. package/paseo-plugin.json +10 -0
  37. package/server/settings.ts +10 -0
  38. package/shared/blocks.ts +15 -0
  39. package/shared/chat.ts +174 -0
  40. package/shared/forecast.ts +233 -0
  41. package/shared/format.ts +47 -1
  42. package/shared/governor.ts +3 -0
  43. package/shared/smart-session.ts +16 -0
  44. package/client/navigation.tsx +0 -14
  45. package/client/surface.tsx +0 -659
  46. package/docs/screenshots/smart-compact-handoff.png +0 -0
@@ -1,659 +0,0 @@
1
- import { type PluginSurfaceProps, useRpc, useSettings } from "@getpaseo/plugin/client";
2
- import { Icon, useToast } from "@getpaseo/plugin/client/react-native";
3
- import { useQuery, useQueryClient } from "@tanstack/react-query";
4
- import React, { useEffect, useState } from "react";
5
- import { Pressable, ScrollView, Text, TextInput, View } from "react-native";
6
-
7
- import { DailyBars, HourHeatmap, RankedTotals } from "./charts";
8
- import { refreshPills } from "./pill";
9
- import { enrolmentState } from "../shared/governor";
10
- import { formatRelative, formatTokens, isInteresting, windowLabel } from "../shared/format";
11
- import {
12
- budgetStatus,
13
- contextStatus,
14
- getSettings,
15
- installStatus,
16
- setSettings,
17
- spendSummary,
18
- } from "../shared/smart-session";
19
- import { withCompactThreshold } from "../shared/thresholds";
20
- import { guardPreferences, statusRpc as guardStatus } from "../shared/guard/contracts";
21
- import { isPending } from "../shared/guard/policy";
22
- import { governorNotice, pauseOwnerNote, pausedSessions, pausePresentation } from "../shared/guard/presentation";
23
- import { refreshGuardPills } from "./guard/pills";
24
- import { GUARD_SURFACES } from "./guard/surfaces";
25
-
26
- type Theme = PluginSurfaceProps["theme"];
27
-
28
- /** Green under two thirds, amber past it, red once there is little room left. */
29
- function toneFor(theme: Theme, pct: number): string {
30
- if (pct >= 90) return theme.colors.statusDanger;
31
- if (pct >= 66) return theme.colors.statusWarning;
32
- return theme.colors.accent;
33
- }
34
-
35
- function Bar({ theme, pct }: { theme: Theme; pct: number }) {
36
- const clamped = Math.max(0, Math.min(100, pct));
37
- return (
38
- <View style={{ height: 6, borderRadius: 3, backgroundColor: theme.colors.surface2, overflow: "hidden" }}>
39
- <View style={{ width: `${clamped}%`, height: 6, backgroundColor: toneFor(theme, clamped) }} />
40
- </View>
41
- );
42
- }
43
-
44
- function Row({
45
- theme,
46
- title,
47
- detail,
48
- pct,
49
- trailing,
50
- }: {
51
- theme: Theme;
52
- title: string;
53
- detail: string;
54
- pct: number;
55
- trailing: string;
56
- }) {
57
- return (
58
- <View style={{ gap: 6, paddingVertical: 8 }}>
59
- <View style={{ flexDirection: "row", alignItems: "baseline", gap: 8 }}>
60
- <Text style={{ color: theme.colors.foreground, fontWeight: "600", flexGrow: 1 }}>{title}</Text>
61
- <Text style={{ color: toneFor(theme, pct), fontVariant: ["tabular-nums"] }}>{trailing}</Text>
62
- </View>
63
- <Bar theme={theme} pct={pct} />
64
- <Text style={{ color: theme.colors.foregroundMuted, fontSize: 12 }}>{detail}</Text>
65
- </View>
66
- );
67
- }
68
-
69
- function Section({ theme, title, children }: { theme: Theme; title: string; children: React.ReactNode }) {
70
- return (
71
- <View style={{ gap: 2 }}>
72
- <Text
73
- style={{
74
- color: theme.colors.foregroundMuted,
75
- fontSize: 11,
76
- letterSpacing: 1,
77
- textTransform: "uppercase",
78
- marginBottom: 4,
79
- }}
80
- >
81
- {title}
82
- </Text>
83
- {children}
84
- </View>
85
- );
86
- }
87
-
88
- /**
89
- * A switch, and the one thing it controls.
90
- *
91
- * `disabled` is for the options that only mean something while Smart Compact is on.
92
- * They stay visible and keep showing their own value — hiding them would make the
93
- * master switch look like it had erased them — but they dim and stop responding,
94
- * which is what says "this one depends on the one above".
95
- */
96
- function Toggle({
97
- theme,
98
- on,
99
- label,
100
- detail,
101
- onPress,
102
- disabled = false,
103
- }: {
104
- theme: Theme;
105
- on: boolean;
106
- label: string;
107
- detail: string;
108
- onPress: () => void;
109
- disabled?: boolean;
110
- }) {
111
- return (
112
- <Pressable
113
- onPress={disabled ? undefined : onPress}
114
- disabled={disabled}
115
- style={{
116
- flexDirection: "row",
117
- alignItems: "center",
118
- gap: 12,
119
- paddingVertical: 10,
120
- paddingHorizontal: 12,
121
- borderRadius: 8,
122
- borderWidth: 1,
123
- borderColor: theme.colors.border,
124
- backgroundColor: theme.colors.surface1,
125
- opacity: disabled ? 0.45 : 1,
126
- }}
127
- >
128
- <View
129
- style={{
130
- width: 36,
131
- height: 20,
132
- borderRadius: 10,
133
- padding: 2,
134
- backgroundColor: on ? theme.colors.accent : theme.colors.surface2,
135
- justifyContent: "center",
136
- alignItems: on ? "flex-end" : "flex-start",
137
- }}
138
- >
139
- <View style={{ width: 16, height: 16, borderRadius: 8, backgroundColor: theme.colors.accentForeground }} />
140
- </View>
141
- <View style={{ flexShrink: 1, gap: 2 }}>
142
- <Text style={{ color: theme.colors.foreground, fontWeight: "600" }}>{label}</Text>
143
- <Text style={{ color: theme.colors.foregroundMuted, fontSize: 12 }}>{detail}</Text>
144
- </View>
145
- </Pressable>
146
- );
147
- }
148
-
149
- function CompactThreshold({
150
- theme,
151
- label,
152
- detail,
153
- value,
154
- disabled,
155
- onSave,
156
- }: {
157
- theme: Theme;
158
- label: string;
159
- detail: string;
160
- value: number;
161
- disabled: boolean;
162
- onSave: (value: number) => Promise<void>;
163
- }) {
164
- const [draft, setDraft] = useState(String(value));
165
- const [saving, setSaving] = useState(false);
166
- useEffect(() => setDraft(String(value)), [value]);
167
-
168
- const parsed = Number(draft.trim());
169
- const valid = Number.isInteger(parsed) && parsed >= 1 && parsed <= 99;
170
- const changed = valid && parsed !== value;
171
-
172
- function save() {
173
- if (disabled || !changed || saving) return;
174
- setSaving(true);
175
- void onSave(parsed).finally(() => setSaving(false));
176
- }
177
-
178
- return (
179
- <View
180
- style={{
181
- gap: 8,
182
- paddingVertical: 10,
183
- paddingHorizontal: 12,
184
- borderRadius: 8,
185
- borderWidth: 1,
186
- borderColor: theme.colors.border,
187
- backgroundColor: theme.colors.surface1,
188
- opacity: disabled ? 0.45 : 1,
189
- }}
190
- >
191
- <View style={{ flexDirection: "row", alignItems: "center", gap: 12, flexWrap: "wrap" }}>
192
- <View style={{ flexGrow: 1, flexShrink: 1, gap: 2 }}>
193
- <Text style={{ color: theme.colors.foreground, fontWeight: "600" }}>{label}</Text>
194
- <Text style={{ color: theme.colors.foregroundMuted, fontSize: 12 }}>{detail}</Text>
195
- </View>
196
- <View style={{ flexDirection: "row", alignItems: "center", gap: 6, flexShrink: 0 }}>
197
- <TextInput
198
- value={draft}
199
- onChangeText={setDraft}
200
- onSubmitEditing={save}
201
- editable={!disabled && !saving}
202
- keyboardType="number-pad"
203
- maxLength={2}
204
- selectTextOnFocus
205
- accessibilityLabel={`${label} percentage`}
206
- style={{
207
- width: 48,
208
- paddingVertical: 6,
209
- paddingHorizontal: 8,
210
- borderRadius: 6,
211
- borderWidth: 1,
212
- borderColor: valid ? theme.colors.border : theme.colors.statusDanger,
213
- backgroundColor: theme.colors.surface0,
214
- color: theme.colors.foreground,
215
- textAlign: "right",
216
- fontVariant: ["tabular-nums"],
217
- }}
218
- />
219
- <Text style={{ color: theme.colors.foregroundMuted }}>%</Text>
220
- <Pressable
221
- onPress={save}
222
- disabled={disabled || !changed || saving}
223
- style={{
224
- paddingVertical: 7,
225
- paddingHorizontal: 10,
226
- borderRadius: 6,
227
- backgroundColor: changed ? theme.colors.accent : theme.colors.surface2,
228
- opacity: disabled || !changed || saving ? 0.5 : 1,
229
- }}
230
- >
231
- <Text style={{ color: changed ? theme.colors.accentForeground : theme.colors.foregroundMuted }}>
232
- {saving ? "Saving…" : "Save"}
233
- </Text>
234
- </Pressable>
235
- </View>
236
- </View>
237
- {!valid ? (
238
- <Text style={{ color: theme.colors.statusDanger, fontSize: 11 }}>Enter a whole number from 1 to 99.</Text>
239
- ) : null}
240
- </View>
241
- );
242
- }
243
-
244
- /** A quiet text link, for the places this section points to rather than controls. */
245
- function Link({ theme, label, onPress }: { theme: Theme; label: string; onPress: () => void }) {
246
- return (
247
- <Pressable accessibilityRole="link" onPress={onPress} style={({ pressed }) => ({ minHeight: 32, justifyContent: "center", opacity: pressed ? 0.6 : 1 })}>
248
- <Text style={{ color: theme.colors.foreground, fontSize: 12, textDecorationLine: "underline" }}>{label}</Text>
249
- </Pressable>
250
- );
251
- }
252
-
253
- /**
254
- * Session Guard, in brief: whether it is on, who is running it, and what is paused.
255
- *
256
- * The full controls live on its own settings screen; this is the switch and the
257
- * answer to "is anything waiting for a reset", next to the plan limits that cause it.
258
- */
259
- function SessionGuardSection({ theme, openSurface }: { theme: Theme; openSurface?: (id: string) => void }) {
260
- const settings = useSettings(guardPreferences);
261
- const readStatus = useRpc(guardStatus);
262
- const toast = useToast();
263
- const statusQuery = useQuery({ queryKey: ["smart-session", "guard"], queryFn: () => readStatus({}), refetchInterval: 15_000 });
264
- const status = statusQuery.data;
265
- const values = settings.status === "ready" ? settings.values : undefined;
266
- // A change made here or on another client moves the pills without waiting out their poll.
267
- useEffect(() => {
268
- if (values !== undefined) refreshGuardPills();
269
- }, [values]);
270
- if (settings.status !== "ready") return null;
271
- const policy = settings.values;
272
- const waiting = status === undefined ? [] : pausedSessions(status.pauses);
273
-
274
- function toggle() {
275
- if (settings.status !== "ready") return;
276
- const next = !settings.values.enabled;
277
- void settings.save({ ...settings.values, enabled: next }, settings.revision).then((saved) => {
278
- if (!saved) return toast.error("Session Guard could not be changed. Try again.");
279
- toast.show(next ? "Session Guard on" : "Session Guard off");
280
- void statusQuery.refetch();
281
- });
282
- }
283
-
284
- const owned = status?.governor === "active";
285
- const owner = status === undefined ? null : pauseOwnerNote(status);
286
- const notice = status === undefined ? null : governorNotice(status);
287
- const count = `${waiting.length} paused session${waiting.length === 1 ? "" : "s"}`;
288
- const detail = !policy.enabled
289
- ? waiting.length > 0 && owned
290
- ? `Off. ${count} still ${waiting.length === 1 ? "waits" : "wait"} for the reset; cancel ${waiting.length === 1 ? "it" : "them"} from Paused sessions, or turn this back on.`
291
- : waiting.length > 0
292
- ? `Off. ${count} below ${waiting.length === 1 ? "was" : "were"} saved by the standalone Session Guard plugin and ${waiting.length === 1 ? "is" : "are"} not managed here.`
293
- : `Off. Turn on to pause sessions at ${policy.thresholdPct}% of the provider's session window and continue them in the same conversation after it resets.`
294
- : !owned && notice !== null && status?.governor !== "off"
295
- ? `On, but not pausing anything here. ${notice.hint}`
296
- : `On. ${policy.protectByDefault ? "Every session" : "Each session you opt in"} pauses at ${policy.thresholdPct}% of the session window${
297
- policy.includeNativeSubagents ? ", native subagents included," : ""
298
- } and ${policy.autoResume ? "continues by itself" : "waits for you"} after the reset.`;
299
-
300
- return (
301
- <Section theme={theme} title="Session Guard">
302
- <Toggle theme={theme} on={policy.enabled} label="Session Guard" detail={detail} onPress={toggle} disabled={settings.saving} />
303
- {waiting.map((pause) => {
304
- const presentation = pausePresentation(pause, status!.policy);
305
- return (
306
- <View key={pause.id} style={{ paddingVertical: 6, gap: 2 }}>
307
- <Text style={{ color: pause.phase === "attention" ? theme.colors.statusDanger : theme.colors.foreground }}>{presentation.title}</Text>
308
- <Text style={{ color: theme.colors.foregroundMuted, fontSize: 12 }}>{`${owner ?? presentation.state} · ${presentation.resetLabel}`}</Text>
309
- </View>
310
- );
311
- })}
312
- {openSurface === undefined ? null : (
313
- <View style={{ flexDirection: "row", gap: 16, flexWrap: "wrap", marginTop: 4 }}>
314
- {status?.pauses.some(isPending) ? <Link theme={theme} label="Paused sessions" onPress={() => openSurface(GUARD_SURFACES.paused)} /> : null}
315
- <Link theme={theme} label="Session Guard settings" onPress={() => openSurface(GUARD_SURFACES.settings)} />
316
- </View>
317
- )}
318
- </Section>
319
- );
320
- }
321
-
322
- export function SmartSessionSurface({ theme, layout, openSurface }: PluginSurfaceProps & { openSurface?: (id: string) => void }) {
323
- const budget = useRpc(budgetStatus);
324
- const context = useRpc(contextStatus);
325
- const spend = useRpc(spendSummary);
326
- const readSettings = useRpc(getSettings);
327
- const writeSettings = useRpc(setSettings);
328
- const readEnrolment = useRpc(enrolmentState);
329
- const readInstall = useRpc(installStatus);
330
- const toast = useToast();
331
- const queryClient = useQueryClient();
332
-
333
- // The daemon serves usage from a five-minute cache, so polling it harder than
334
- // the recorder does would only redraw the same number.
335
- const budgetQuery = useQuery({ queryKey: ["smart-session", "budget"], queryFn: () => budget({}), refetchInterval: 30_000 });
336
- const contextQuery = useQuery({
337
- queryKey: ["smart-session", "context"],
338
- queryFn: () => context({}),
339
- refetchInterval: 15_000,
340
- });
341
-
342
- // Reconstructed from transcripts, which only change when an agent writes a turn.
343
- const spendQuery = useQuery({
344
- queryKey: ["smart-session", "spend"],
345
- queryFn: () => spend({ days: 30 }),
346
- refetchInterval: 5 * 60_000,
347
- });
348
-
349
- const settingsQuery = useQuery({
350
- queryKey: ["smart-session", "settings"],
351
- queryFn: () => readSettings({}),
352
- refetchInterval: 60_000,
353
- });
354
-
355
- // Cheap on every call after the first: with nothing to reconcile it reads two
356
- // files and writes none.
357
- const installQuery = useQuery({
358
- queryKey: ["smart-session", "install"],
359
- queryFn: () => readInstall({}),
360
- refetchInterval: 5 * 60_000,
361
- });
362
-
363
- const settings = settingsQuery.data?.settings;
364
- const enrolled = settingsQuery.data?.enrolledAgents ?? 0;
365
- const install = installQuery.data?.report;
366
-
367
- function toggleInstallHooks() {
368
- if (settings === undefined) return;
369
- const next = !settings.installHooks;
370
- void writeSettings({ installHooks: next })
371
- .then(() => {
372
- toast.show(next ? "Hooks registered with Claude Code" : "Hooks removed from Claude Code");
373
- return Promise.all([
374
- queryClient.invalidateQueries({ queryKey: ["smart-session", "settings"] }),
375
- queryClient.invalidateQueries({ queryKey: ["smart-session", "install"] }),
376
- ]);
377
- })
378
- .catch((error: unknown) => toast.error(error instanceof Error ? error.message : String(error)));
379
- }
380
-
381
- function toggleEnabled() {
382
- if (settings === undefined) return;
383
- const next = !settings.enabled;
384
- void writeSettings({ enabled: next })
385
- // The pills live in this same client bundle, and the master switch decides
386
- // whether they exist at all, so they are told directly rather than waiting
387
- // out their own refresh interval.
388
- .then(() => refreshPills(() => readEnrolment({})))
389
- .then(() => {
390
- toast.show(next ? "Smart Compact on" : "Smart Compact off");
391
- return queryClient.invalidateQueries({ queryKey: ["smart-session", "settings"] });
392
- })
393
- .catch((error: unknown) => toast.error(error instanceof Error ? error.message : String(error)));
394
- }
395
-
396
- function toggleAutoEnrol() {
397
- if (settings === undefined) return;
398
- const next = !settings.autoEnrol;
399
- void writeSettings({ autoEnrol: next })
400
- .then(() => refreshPills(() => readEnrolment({})))
401
- .then(() => {
402
- toast.show(next ? "Sessions enrol themselves" : "Sessions are enrolled one at a time");
403
- return queryClient.invalidateQueries({ queryKey: ["smart-session", "settings"] });
404
- })
405
- .catch((error: unknown) => toast.error(error instanceof Error ? error.message : String(error)));
406
- }
407
-
408
- function togglePill() {
409
- if (settings === undefined) return;
410
- const next = !settings.showPill;
411
- void writeSettings({ showPill: next })
412
- // The pills live in this same client bundle, so they can be told directly
413
- // rather than waiting out their own refresh interval.
414
- .then(() => refreshPills(() => readEnrolment({})))
415
- .then(() => {
416
- toast.show(next ? "Pill shown on every agent" : "Pill hidden");
417
- return queryClient.invalidateQueries({ queryKey: ["smart-session", "settings"] });
418
- })
419
- .catch((error: unknown) => toast.error(error instanceof Error ? error.message : String(error)));
420
- }
421
-
422
- async function saveCompactThreshold(profile: "large" | "small", compact: number): Promise<void> {
423
- if (settings === undefined) return;
424
- try {
425
- const result = await writeSettings({
426
- thresholds: {
427
- ...settings.thresholds,
428
- [profile]: withCompactThreshold(settings.thresholds[profile], compact),
429
- },
430
- });
431
- queryClient.setQueryData(["smart-session", "settings"], {
432
- settings: result.settings,
433
- enrolledAgents: enrolled,
434
- });
435
- await queryClient.invalidateQueries({ queryKey: ["smart-session", "context"] });
436
- toast.show(`Smart Compact will ask at ${compact}% for ${profile} windows`);
437
- } catch (error: unknown) {
438
- toast.error(error instanceof Error ? error.message : String(error));
439
- }
440
- }
441
-
442
- const padding = layout.compact ? 12 : 20;
443
- const windows = (budgetQuery.data?.windows ?? []).filter(isInteresting);
444
- const agents = contextQuery.data?.agents ?? [];
445
- const recorder = budgetQuery.data?.recorder;
446
- const summary = spendQuery.data?.summary;
447
- const error = budgetQuery.data?.error ?? contextQuery.data?.error ?? null;
448
-
449
- return (
450
- <ScrollView style={{ flex: 1, backgroundColor: theme.colors.surface0 }} contentContainerStyle={{ padding, gap: 24 }}>
451
- <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
452
- <Icon name="Gauge" size={18} color={theme.colors.foreground} />
453
- <Text style={{ color: theme.colors.foreground, fontSize: 18, fontWeight: "700" }}>Smart Session</Text>
454
- </View>
455
-
456
- {error !== null ? (
457
- <Text style={{ color: theme.colors.statusDanger }}>{error}</Text>
458
- ) : null}
459
-
460
- <Section theme={theme} title="Plan limits">
461
- {windows.length === 0 ? (
462
- <Text style={{ color: theme.colors.foregroundMuted }}>
463
- No reading yet. The recorder samples every minute.
464
- </Text>
465
- ) : (
466
- windows.map((window) => (
467
- <Row
468
- key={window.id}
469
- theme={theme}
470
- title={windowLabel(window.id)}
471
- pct={window.pct}
472
- trailing={`${Math.round(window.pct)}%`}
473
- detail={[
474
- window.resetsAt === null ? null : `resets ${formatRelative(window.resetsAt)}`,
475
- window.burnPctPerHour === null || window.burnPctPerHour <= 0
476
- ? null
477
- : `${window.burnPctPerHour.toFixed(1)}%/h`,
478
- window.projectedFullAt === null ? null : `full ${formatRelative(window.projectedFullAt)}`,
479
- ]
480
- .filter((part) => part !== null)
481
- .join(" · ")}
482
- />
483
- ))
484
- )}
485
- </Section>
486
-
487
- <Section theme={theme} title="Agent context">
488
- {agents.length === 0 ? (
489
- <Text style={{ color: theme.colors.foregroundMuted }}>No agent has reported a turn yet.</Text>
490
- ) : (
491
- agents.map((agent) => (
492
- <Row
493
- key={agent.agentId}
494
- theme={theme}
495
- title={agent.title ?? agent.agentId.slice(0, 8)}
496
- pct={agent.usedPct}
497
- trailing={`${agent.usedPct.toFixed(1)}%`}
498
- detail={`${formatTokens(agent.usedTokens)} / ${formatTokens(agent.maxTokens)} · compact at ${
499
- agent.compactAtPct
500
- }% · ${agent.status}${agent.model === null ? "" : ` · ${agent.model}`}`}
501
- />
502
- ))
503
- )}
504
- </Section>
505
-
506
- {summary === undefined || summary.bucketCount === 0 ? null : (
507
- <Section theme={theme} title="Where the tokens went">
508
- <View style={{ flexDirection: "row", gap: 24, marginBottom: 12, flexWrap: "wrap" }}>
509
- <View>
510
- <Text style={{ color: theme.colors.foreground, fontSize: 22, fontWeight: "700" }}>
511
- {formatTokens(summary.totalTokens)}
512
- </Text>
513
- <Text style={{ color: theme.colors.foregroundMuted, fontSize: 11 }}>
514
- billable tokens, last 30 days
515
- </Text>
516
- </View>
517
- <View>
518
- <Text style={{ color: theme.colors.foreground, fontSize: 22, fontWeight: "700" }}>
519
- {Math.round(summary.cacheHitPct)}%
520
- </Text>
521
- <Text style={{ color: theme.colors.foregroundMuted, fontSize: 11 }}>served from cache</Text>
522
- </View>
523
- {summary.subagentTokens === 0 ? null : (
524
- <View>
525
- <Text style={{ color: theme.colors.foreground, fontSize: 22, fontWeight: "700" }}>
526
- {formatTokens(summary.subagentTokens)}
527
- </Text>
528
- <Text style={{ color: theme.colors.foregroundMuted, fontSize: 11 }}>from subagents</Text>
529
- </View>
530
- )}
531
- </View>
532
-
533
- <Text style={{ color: theme.colors.foregroundMuted, fontSize: 12, marginBottom: 6 }}>Tokens per day</Text>
534
- <DailyBars theme={theme} days={summary.byDay.slice(-30)} compact={layout.compact} />
535
-
536
- <Text style={{ color: theme.colors.foregroundMuted, fontSize: 12, marginTop: 16, marginBottom: 6 }}>
537
- Tokens by hour of the week
538
- </Text>
539
- <HourHeatmap theme={theme} cells={summary.heatmap} compact={layout.compact} />
540
-
541
- <Text style={{ color: theme.colors.foregroundMuted, fontSize: 12, marginTop: 16, marginBottom: 6 }}>
542
- By workspace
543
- </Text>
544
- <RankedTotals theme={theme} rows={summary.byProject} total={summary.totalTokens} />
545
-
546
- <Text style={{ color: theme.colors.foregroundMuted, fontSize: 12, marginTop: 12, marginBottom: 6 }}>
547
- By model
548
- </Text>
549
- <RankedTotals theme={theme} rows={summary.byModel} total={summary.totalTokens} />
550
- </Section>
551
- )}
552
-
553
- {settings === undefined ? null : (
554
- <Section theme={theme} title="Smart Compact">
555
- <Toggle
556
- theme={theme}
557
- on={settings.enabled}
558
- label="Smart Compact"
559
- detail={
560
- settings.enabled
561
- ? `On. Past ${settings.thresholds.large.compact}% of a large window or ${settings.thresholds.small.compact}% of a small one, a session is asked at the end of a turn whether to compact itself — and compacted only if it says yes. ${enrolled} session${
562
- enrolled === 1 ? "" : "s"
563
- } enrolled.`
564
- : "Off. Nothing is asked and nothing is compacted. A request an agent already made waits rather than failing."
565
- }
566
- onPress={toggleEnabled}
567
- />
568
- <View style={{ marginTop: 8 }}>
569
- <CompactThreshold
570
- theme={theme}
571
- label="Large-window compact threshold"
572
- detail={`For context windows of ${formatTokens(settings.thresholds.largeWindowFrom)} or more. Earlier notice bands move down when needed.`}
573
- value={settings.thresholds.large.compact}
574
- disabled={!settings.enabled}
575
- onSave={(value) => saveCompactThreshold("large", value)}
576
- />
577
- </View>
578
- <View style={{ marginTop: 8 }}>
579
- <CompactThreshold
580
- theme={theme}
581
- label="Small-window compact threshold"
582
- detail={`For context windows below ${formatTokens(settings.thresholds.largeWindowFrom)}. Earlier notice bands move down when needed.`}
583
- value={settings.thresholds.small.compact}
584
- disabled={!settings.enabled}
585
- onSave={(value) => saveCompactThreshold("small", value)}
586
- />
587
- </View>
588
- <View style={{ marginTop: 8 }}>
589
- <Toggle
590
- theme={theme}
591
- on={settings.autoEnrol}
592
- disabled={!settings.enabled}
593
- label="Enrol sessions automatically"
594
- detail="On, a session that has written task state with the checkpoint tool is enrolled by that alone. Off, each session is enrolled by hand from its pill."
595
- onPress={toggleAutoEnrol}
596
- />
597
- </View>
598
- <View style={{ marginTop: 8 }}>
599
- <Toggle
600
- theme={theme}
601
- on={settings.showPill}
602
- disabled={!settings.enabled}
603
- label="Show the pill on every agent"
604
- detail="Puts the smart-compact state on each composer, where pressing it enrols that session or takes it out."
605
- onPress={togglePill}
606
- />
607
- </View>
608
- <View style={{ marginTop: 8 }}>
609
- <Toggle
610
- theme={theme}
611
- on={settings.installHooks}
612
- disabled={!settings.enabled}
613
- label="Register the hooks with Claude Code"
614
- detail={
615
- install === undefined
616
- ? "Keeps this plugin's hooks in ~/.claude/settings.json up to date. Nothing asks a session to compact itself without them."
617
- : install.error !== null
618
- ? install.error
619
- : install.pluginDir === null
620
- ? "Not registered. Nothing will ask a session to compact itself."
621
- : `Registered: ${install.hooks.join(", ")}. Only entries pointing at this plugin are touched; the MCP server is ${
622
- install.mcp === "session" ? "attached when a Paseo session opens" : install.mcp === "unavailable" ? "not registered — the claude CLI was not reachable" : "registered"
623
- }.`
624
- }
625
- onPress={toggleInstallHooks}
626
- />
627
- </View>
628
- <Text style={{ color: theme.colors.foregroundMuted, fontSize: 11, marginTop: 6 }}>
629
- A session is never compacted unless it asks. Paseo puts the question at a turn boundary
630
- and sends the /compact it asked for. The agent also chooses whether to start another turn
631
- afterwards and can supply that follow-up message itself.
632
- </Text>
633
- <Text style={{ color: theme.colors.foregroundMuted, fontSize: 11, marginTop: 4 }}>
634
- {`Asked at ${settings.thresholds.large.compact}% of a ${formatTokens(settings.thresholds.largeWindowFrom)}+ window (${formatTokens(
635
- Math.round((settings.thresholds.large.compact / 100) * 1_000_000),
636
- )} tokens on a 1M session), or ${settings.thresholds.small.compact}% of a smaller one. What tires a context is its absolute size, not its share of the window.`}
637
- </Text>
638
- </Section>
639
- )}
640
-
641
- <SessionGuardSection theme={theme} openSurface={openSurface} />
642
-
643
- {recorder === undefined ? null : (
644
- <Section theme={theme} title="Recorder">
645
- <Text style={{ color: theme.colors.foregroundMuted, fontSize: 12 }}>
646
- {recorder.newestAt === null
647
- ? "Nothing recorded yet."
648
- : `Newest reading ${formatRelative(recorder.newestAt)} from ${recorder.source ?? "?"} · ${
649
- recorder.samplesHeld
650
- } samples held${
651
- recorder.firstSampleAt === null ? "" : ` since ${formatRelative(recorder.firstSampleAt)}`
652
- }`}
653
- </Text>
654
- <Text style={{ color: theme.colors.foregroundMuted, fontSize: 11 }}>{recorder.dataDir}</Text>
655
- </Section>
656
- )}
657
- </ScrollView>
658
- );
659
- }