paseo-smart-session 2.1.0 → 3.0.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.
- package/README.md +40 -11
- package/client/agents.ts +26 -4
- package/client/charts.tsx +277 -152
- package/client/chat.tsx +122 -0
- package/client/display.tsx +55 -33
- package/client/guard/index.tsx +14 -16
- package/client/guard/paused.tsx +125 -38
- package/client/guard/pill-icons.tsx +1 -1
- package/client/guard/pills.ts +16 -6
- package/client/guard/surfaces.ts +3 -2
- package/client/guard/timeline.tsx +116 -56
- package/client/kit.tsx +144 -0
- package/client/live.ts +49 -0
- package/client/motion.tsx +94 -0
- package/client/overview.tsx +373 -0
- package/client/pill-store.ts +77 -0
- package/client/pill.tsx +83 -260
- package/client/popover.tsx +113 -0
- package/client/settings.tsx +105 -0
- package/client/theme.ts +56 -0
- package/docs/media/smart-session-chat.png +0 -0
- package/docs/media/smart-session-flow.gif +0 -0
- package/docs/media/smart-session-guard.png +0 -0
- package/docs/media/smart-session-hero-light.png +0 -0
- package/docs/media/smart-session-hero.png +0 -0
- package/docs/media/smart-session-mobile.png +0 -0
- package/docs/media/smart-session-pill.png +0 -0
- package/docs/navigation-verification.md +6 -6
- package/docs/screenshots/display-settings-desktop.png +0 -0
- package/docs/screenshots/display-settings-mobile.png +0 -0
- package/docs/session-guard.md +3 -3
- package/index.client.tsx +47 -35
- package/index.server.ts +14 -5
- package/mcp.mjs +1 -1
- package/package.json +1 -1
- package/paseo-plugin.json +10 -0
- package/server/settings.ts +10 -0
- package/shared/blocks.ts +15 -0
- package/shared/chat.ts +174 -0
- package/shared/forecast.ts +233 -0
- package/shared/format.ts +47 -1
- package/shared/governor.ts +3 -0
- package/shared/smart-session.ts +16 -0
- package/client/navigation.tsx +0 -14
- package/client/surface.tsx +0 -659
- package/docs/screenshots/smart-compact-handoff.png +0 -0
package/client/surface.tsx
DELETED
|
@@ -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
|
-
}
|
|
Binary file
|