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.
- 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/pill.tsx
CHANGED
|
@@ -1,297 +1,116 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The
|
|
2
|
+
* The Smart Compact pill, on every agent's composer.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* It answers two questions where they matter, next to the box you are about to type
|
|
5
|
+
* into: how full is this session's context, and will Smart Compact ask it to compact
|
|
6
|
+
* itself. The icon is a ring that fills with the context and carries a tick at the
|
|
7
|
+
* compact line; the label is the percentage. Pressing it opens a popover with the
|
|
8
|
+
* detail and the enrolment switch.
|
|
8
9
|
*
|
|
9
10
|
* State lives in this module rather than in each pill, because the pill and the
|
|
10
|
-
* things that change it (its own
|
|
11
|
-
*
|
|
12
|
-
* mounted pill redraws together.
|
|
11
|
+
* things that change it (its own popover, the Command Center item, the overview and
|
|
12
|
+
* settings) are all in this one client bundle. One store, one fetch, and every
|
|
13
|
+
* mounted pill redraws together. Context occupancy comes from the shared agent
|
|
14
|
+
* directory, which already carries each agent's last usage, so the ring moves with
|
|
15
|
+
* every turn without a poll of its own.
|
|
13
16
|
*
|
|
14
|
-
*
|
|
15
|
-
* state for "on but nothing will happen", because there is no longer a global
|
|
16
|
-
* switch that can make an enrolled session inert — the master switch hides the pill
|
|
17
|
-
* outright instead.
|
|
17
|
+
* With Smart Compact off there is no pill at all: there is nothing for one to say.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
import type { PluginClientContext,
|
|
21
|
-
import {
|
|
22
|
-
import React, { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
|
|
23
|
-
import { Text, View } from "react-native";
|
|
20
|
+
import type { PluginButtonIconProps, PluginClientContext, PluginButtonRegistration } from "@getpaseo/plugin/client";
|
|
21
|
+
import { useSyncExternalStore } from "react";
|
|
24
22
|
|
|
25
|
-
import {
|
|
26
|
-
import { enrolmentState
|
|
27
|
-
import
|
|
23
|
+
import { contextOutlook } from "../shared/forecast";
|
|
24
|
+
import { enrolmentState } from "../shared/governor";
|
|
25
|
+
import { pillSnapshot, refreshPills, state as currentState, subscribePills } from "./pill-store";
|
|
26
|
+
|
|
27
|
+
export { refreshPills } from "./pill-store";
|
|
28
|
+
import { profileFor } from "../shared/thresholds";
|
|
29
|
+
import type { Thresholds } from "../shared/thresholds";
|
|
30
|
+
import type { AgentDirectory, DirectoryAgent } from "./agents";
|
|
31
|
+
import { Ring, toneFill } from "./charts";
|
|
32
|
+
import { makeContextPopover } from "./popover";
|
|
28
33
|
|
|
29
34
|
/** How often the client re-reads enrolment, for changes made somewhere else. */
|
|
30
35
|
const REFRESH_MS = 20_000;
|
|
31
36
|
|
|
32
|
-
/**
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
readonly showPill: boolean;
|
|
50
|
-
readonly enrolled: ReadonlySet<string>;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* Hidden until the daemon says otherwise.
|
|
55
|
-
*
|
|
56
|
-
* The alternative — assume shown, then retract — flashes a pill at everyone who
|
|
57
|
-
* turned it off, every time the app loads.
|
|
58
|
-
*/
|
|
59
|
-
let state: PillState = { showPill: false, enrolled: new Set() };
|
|
60
|
-
|
|
61
|
-
const listeners = new Set<() => void>();
|
|
62
|
-
|
|
63
|
-
function subscribe(listener: () => void): () => void {
|
|
64
|
-
listeners.add(listener);
|
|
65
|
-
return () => {
|
|
66
|
-
listeners.delete(listener);
|
|
37
|
+
/** Everything a pill shows about one agent, in one place for the icon, label and title. */
|
|
38
|
+
export function pillPresentation(agent: Pick<DirectoryAgent, "usedTokens" | "maxTokens"> | undefined, enrolled: boolean, thresholds: Thresholds | null) {
|
|
39
|
+
const known = agent !== undefined && agent.usedTokens !== null && agent.maxTokens !== null;
|
|
40
|
+
if (!known) {
|
|
41
|
+
return {
|
|
42
|
+
label: "Smart Compact",
|
|
43
|
+
title: enrolled ? "Smart Compact on · no context reading yet" : "Smart Compact off · no context reading yet",
|
|
44
|
+
fraction: 0, mark: null as number | null, tone: "default" as const, pct: null as number | null,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
const compactAtPct = profileFor(agent.maxTokens!, thresholds ?? undefined).compact;
|
|
48
|
+
const outlook = contextOutlook({ usedTokens: agent.usedTokens!, maxTokens: agent.maxTokens!, compactAtPct });
|
|
49
|
+
const pct = Math.round(outlook.pct);
|
|
50
|
+
return {
|
|
51
|
+
label: `${pct}%`,
|
|
52
|
+
title: `Context ${pct}% full · ${enrolled ? `Smart Compact asks at ${compactAtPct}%` : "Smart Compact off for this session"}`,
|
|
53
|
+
fraction: outlook.pct / 100, mark: compactAtPct / 100, tone: outlook.tone, pct,
|
|
67
54
|
};
|
|
68
55
|
}
|
|
69
56
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/**
|
|
84
|
-
* Re-reads enrolment and redraws every pill.
|
|
85
|
-
*
|
|
86
|
-
* Takes the call rather than making it: the same fetch is reachable from the client
|
|
87
|
-
* entry, a Command Center item and the surface, and each of those holds a different
|
|
88
|
-
* flavour of RPC caller.
|
|
89
|
-
*/
|
|
90
|
-
export async function refreshPills(fetch: () => Promise<EnrolmentState>): Promise<void> {
|
|
91
|
-
const next = await fetch();
|
|
92
|
-
publish({
|
|
93
|
-
// With the feature off there is nothing for a pill to say. Drawing a third,
|
|
94
|
-
// "paused" state would just be a second way of spelling off.
|
|
95
|
-
showPill: next.enabled && next.showPill,
|
|
96
|
-
enrolled: enrolmentSet(next.agents),
|
|
97
|
-
});
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
/**
|
|
101
|
-
* One icon, and the words only when you ask for them.
|
|
102
|
-
*
|
|
103
|
-
* The composer track is one line shared with Paseo's own pills, and this one has
|
|
104
|
-
* nothing to say that changes minute to minute — so it states itself in colour and
|
|
105
|
-
* spells it out on hover. Pointer platforms get the tooltip; touch hosts never fire
|
|
106
|
-
* these events and read the colour, or press.
|
|
107
|
-
*
|
|
108
|
-
* Paseo owns the pressable, the border and the spinner; this owns what is in it.
|
|
109
|
-
*/
|
|
110
|
-
type LegacyPillProps = PluginHostProps & { workspaceId: string; agentId: string };
|
|
111
|
-
|
|
112
|
-
export function AutoCompactPill({ theme, agentId }: LegacyPillProps) {
|
|
113
|
-
const current = useSyncExternalStore(subscribe, snapshot, snapshot);
|
|
114
|
-
const [tooltip, setTooltip] = useState(false);
|
|
115
|
-
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
116
|
-
|
|
117
|
-
const clearHoverTimer = useCallback(() => {
|
|
118
|
-
if (timer.current === null) return;
|
|
119
|
-
clearTimeout(timer.current);
|
|
120
|
-
timer.current = null;
|
|
121
|
-
}, []);
|
|
122
|
-
useEffect(() => clearHoverTimer, [clearHoverTimer]);
|
|
123
|
-
|
|
124
|
-
const onPointerEnter = useCallback(() => {
|
|
125
|
-
if (timer.current !== null) return;
|
|
126
|
-
timer.current = setTimeout(() => {
|
|
127
|
-
timer.current = null;
|
|
128
|
-
setTooltip(true);
|
|
129
|
-
}, HOVER_DELAY_MS);
|
|
130
|
-
}, []);
|
|
131
|
-
|
|
132
|
-
const onPointerLeave = useCallback(() => {
|
|
133
|
-
clearHoverTimer();
|
|
134
|
-
setTooltip(false);
|
|
135
|
-
}, [clearHoverTimer]);
|
|
136
|
-
|
|
137
|
-
const enrolled = current.enrolled.has(agentId);
|
|
138
|
-
const color = enrolled ? theme.colors.accent : theme.colors.foregroundMuted;
|
|
139
|
-
return (
|
|
140
|
-
<View
|
|
141
|
-
style={{
|
|
142
|
-
flexDirection: "row",
|
|
143
|
-
alignItems: "center",
|
|
144
|
-
justifyContent: "center",
|
|
145
|
-
// Stretch plus the cancelled padding is what makes the whole pill hoverable,
|
|
146
|
-
// vertically as well: Paseo's pill is 32px high and this glyph is 14.
|
|
147
|
-
alignSelf: "stretch",
|
|
148
|
-
marginVertical: -HOST_PILL_INSET.vertical,
|
|
149
|
-
marginHorizontal: -HOST_PILL_INSET.horizontal,
|
|
150
|
-
paddingVertical: HOST_PILL_INSET.vertical,
|
|
151
|
-
paddingHorizontal: HOST_PILL_INSET.horizontal,
|
|
152
|
-
}}
|
|
153
|
-
onPointerEnter={onPointerEnter}
|
|
154
|
-
onPointerLeave={onPointerLeave}
|
|
155
|
-
>
|
|
156
|
-
<Icon name="FoldVertical" size={14} color={color} />
|
|
157
|
-
{tooltip ? (
|
|
158
|
-
// Drawn above the pill rather than beside it: the track bar is one line
|
|
159
|
-
// high, and widening the pill on hover would shove its neighbours along.
|
|
160
|
-
<View
|
|
161
|
-
style={{
|
|
162
|
-
position: "absolute",
|
|
163
|
-
bottom: "100%",
|
|
164
|
-
left: 0,
|
|
165
|
-
marginBottom: 8,
|
|
166
|
-
gap: 2,
|
|
167
|
-
paddingVertical: 6,
|
|
168
|
-
paddingHorizontal: 10,
|
|
169
|
-
borderRadius: 8,
|
|
170
|
-
borderWidth: 1,
|
|
171
|
-
borderColor: theme.colors.border,
|
|
172
|
-
backgroundColor: theme.colors.surface1,
|
|
173
|
-
}}
|
|
174
|
-
>
|
|
175
|
-
<Text numberOfLines={1} style={{ color: theme.colors.foreground, fontSize: 13 }}>
|
|
176
|
-
{smartCompactLabel({ enrolled })}
|
|
177
|
-
</Text>
|
|
178
|
-
<Text numberOfLines={1} style={{ color: theme.colors.foregroundMuted, fontSize: 11 }}>
|
|
179
|
-
{enrolled ? "Press to take this session out" : "Press to enrol this session"}
|
|
180
|
-
</Text>
|
|
181
|
-
</View>
|
|
182
|
-
) : null}
|
|
183
|
-
</View>
|
|
184
|
-
);
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
/**
|
|
188
|
-
* The v0.8 button-descriptor icon. The host owns its tooltip and interaction;
|
|
189
|
-
* this component only paints the current state inside the icon slot.
|
|
190
|
-
*/
|
|
191
|
-
function AutoCompactIcon({
|
|
192
|
-
theme,
|
|
193
|
-
agentId,
|
|
194
|
-
size,
|
|
195
|
-
}: PluginHostProps & { agentId: string; size: number; color: string }) {
|
|
196
|
-
const current = useSyncExternalStore(subscribe, snapshot, snapshot);
|
|
197
|
-
const color = current.enrolled.has(agentId) ? theme.colors.accent : theme.colors.foregroundMuted;
|
|
198
|
-
return <Icon name="FoldVertical" size={size} color={color} />;
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
/**
|
|
202
|
-
* The published 0.8 descriptor API returns this handle. The 0.8.0-beta.1 npm
|
|
203
|
-
* package and app still return the legacy remover function, so registrations
|
|
204
|
-
* carry both input shapes and this adapter accepts either result. Remove these
|
|
205
|
-
* legacy fields once the beta client and SDK catch up with the deployed docs.
|
|
206
|
-
*/
|
|
207
|
-
interface PillRegistration {
|
|
208
|
-
update(patch: { title?: string; label?: string; visible?: boolean; disabled?: boolean }): void;
|
|
209
|
-
remove(): void;
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
type PillHandle = (() => void) | PillRegistration;
|
|
213
|
-
|
|
214
|
-
function removePill(handle: PillHandle): void {
|
|
215
|
-
if (typeof handle === "function") handle();
|
|
216
|
-
else handle.remove();
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
function updatePill(handle: PillHandle, title: string): void {
|
|
220
|
-
if (typeof handle !== "function") handle.update({ title });
|
|
57
|
+
/** One live icon per pill: the ring, muted while the session is not enrolled. */
|
|
58
|
+
function makeIcon(agents: AgentDirectory) {
|
|
59
|
+
return function ContextRingIcon(props: PluginButtonIconProps) {
|
|
60
|
+
const current = useSyncExternalStore(subscribePills, pillSnapshot, pillSnapshot);
|
|
61
|
+
useSyncExternalStore(agents.subscribe, agents.version, agents.version);
|
|
62
|
+
const agentId = props.context === "agent" ? props.agentId : "";
|
|
63
|
+
const enrolled = current.enrolled.has(agentId);
|
|
64
|
+
const view = pillPresentation(agents.current().get(agentId), enrolled, current.thresholds);
|
|
65
|
+
const c = props.theme.colors;
|
|
66
|
+
const color = view.tone !== "default" ? toneFill(c, view.tone) : enrolled ? c.foreground : c.foregroundMuted;
|
|
67
|
+
return <Ring size={props.size} color={color} fraction={view.fraction} mark={enrolled ? view.mark : null} />;
|
|
68
|
+
};
|
|
221
69
|
}
|
|
222
70
|
|
|
223
71
|
export function contributeClient(client: PluginClientContext, agents: AgentDirectory) {
|
|
224
72
|
/** Every agent this app knows of, and its pill registration while one is up. */
|
|
225
|
-
const tracked = new Map<string, { workspaceId: string; handle:
|
|
73
|
+
const tracked = new Map<string, { workspaceId: string; handle: PluginButtonRegistration | null; signature: string }>();
|
|
74
|
+
const Icon = makeIcon(agents);
|
|
75
|
+
const Content = makeContextPopover(client, agents);
|
|
226
76
|
|
|
227
|
-
function
|
|
228
|
-
const
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
publish({ ...snapshot(), enrolled });
|
|
237
|
-
};
|
|
238
|
-
const title = smartCompactLabel({ enrolled: snapshot().enrolled.has(agentId) });
|
|
239
|
-
const addComposerPill = client.addComposerPill as unknown as (contribution: {
|
|
240
|
-
id: string;
|
|
241
|
-
workspaceId: string;
|
|
242
|
-
agentId: string;
|
|
243
|
-
button: {
|
|
244
|
-
title: string;
|
|
245
|
-
icon: typeof AutoCompactIcon;
|
|
246
|
-
label: string;
|
|
247
|
-
behavior: { kind: "action"; onPress: () => Promise<void> };
|
|
248
|
-
};
|
|
249
|
-
title: string;
|
|
250
|
-
Component: typeof AutoCompactPill;
|
|
251
|
-
onPress: () => Promise<void>;
|
|
252
|
-
}) => PillHandle;
|
|
253
|
-
entry.handle = addComposerPill({
|
|
77
|
+
function describe(agentId: string) {
|
|
78
|
+
const view = pillPresentation(agents.current().get(agentId), currentState().enrolled.has(agentId), currentState().thresholds);
|
|
79
|
+
return { label: view.label, title: view.title };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function register(agentId: string, entry: { workspaceId: string; handle: PluginButtonRegistration | null; signature: string }): void {
|
|
83
|
+
const { label, title } = describe(agentId);
|
|
84
|
+
entry.signature = `${label}|${title}`;
|
|
85
|
+
entry.handle = client.addComposerPill({
|
|
254
86
|
id: "smart-compact",
|
|
255
87
|
workspaceId: entry.workspaceId,
|
|
256
88
|
agentId,
|
|
257
|
-
button: {
|
|
258
|
-
title,
|
|
259
|
-
icon: AutoCompactIcon,
|
|
260
|
-
label: "Smart Compact",
|
|
261
|
-
behavior: { kind: "action", onPress: toggle },
|
|
262
|
-
},
|
|
263
|
-
// 0.8.0-beta.1 compatibility; ignored by the documented descriptor host.
|
|
264
|
-
title,
|
|
265
|
-
Component: AutoCompactPill,
|
|
266
|
-
onPress: toggle,
|
|
89
|
+
button: { title, label, icon: Icon, behavior: { kind: "popover", Content } },
|
|
267
90
|
});
|
|
268
91
|
}
|
|
269
92
|
|
|
270
93
|
/** Brings the registrations in line with the store, whichever way it moved. */
|
|
271
94
|
function sync(): void {
|
|
272
95
|
for (const [agentId, entry] of tracked) {
|
|
273
|
-
if (
|
|
274
|
-
else if (!
|
|
275
|
-
|
|
96
|
+
if (currentState().showPill && entry.handle === null) register(agentId, entry);
|
|
97
|
+
else if (!currentState().showPill && entry.handle !== null) {
|
|
98
|
+
entry.handle.remove();
|
|
276
99
|
entry.handle = null;
|
|
277
100
|
} else if (entry.handle !== null) {
|
|
278
|
-
|
|
101
|
+
const { label, title } = describe(agentId);
|
|
102
|
+
const signature = `${label}|${title}`;
|
|
103
|
+
// Only a changed label is sent: a fresh behaviour would remount an open popover.
|
|
104
|
+
if (signature !== entry.signature) {
|
|
105
|
+
entry.signature = signature;
|
|
106
|
+
entry.handle.update({ label, title });
|
|
107
|
+
}
|
|
279
108
|
}
|
|
280
109
|
}
|
|
281
110
|
}
|
|
282
111
|
|
|
283
|
-
function track(agentId: string, workspaceId: string): void {
|
|
284
|
-
const existing = tracked.get(agentId);
|
|
285
|
-
if (existing !== undefined && existing.workspaceId === workspaceId) return;
|
|
286
|
-
// A pill is pinned to one workspace, so an agent that moved needs a new one.
|
|
287
|
-
if (existing?.handle !== null && existing?.handle !== undefined) removePill(existing.handle);
|
|
288
|
-
tracked.set(agentId, { workspaceId, handle: null });
|
|
289
|
-
sync();
|
|
290
|
-
}
|
|
291
|
-
|
|
292
112
|
function forget(agentId: string): void {
|
|
293
|
-
|
|
294
|
-
if (handle !== null && handle !== undefined) removePill(handle);
|
|
113
|
+
tracked.get(agentId)?.handle?.remove();
|
|
295
114
|
tracked.delete(agentId);
|
|
296
115
|
}
|
|
297
116
|
|
|
@@ -301,12 +120,16 @@ export function contributeClient(client: PluginClientContext, agents: AgentDirec
|
|
|
301
120
|
for (const [agentId, entry] of [...tracked]) {
|
|
302
121
|
if (current.get(agentId)?.workspaceId !== entry.workspaceId) forget(agentId);
|
|
303
122
|
}
|
|
304
|
-
for (const [agentId, agent] of current)
|
|
123
|
+
for (const [agentId, agent] of current) {
|
|
124
|
+
// A pill is pinned to one workspace, so an agent that moved needs a new one.
|
|
125
|
+
if (!tracked.has(agentId)) tracked.set(agentId, { workspaceId: agent.workspaceId, handle: null, signature: "" });
|
|
126
|
+
}
|
|
127
|
+
sync();
|
|
305
128
|
}
|
|
306
129
|
|
|
307
130
|
const refresh = () => refreshPills(() => client.rpc(enrolmentState, {}));
|
|
308
131
|
|
|
309
|
-
const unsubscribeStore =
|
|
132
|
+
const unsubscribeStore = subscribePills(sync);
|
|
310
133
|
const unsubscribeAgents = agents.subscribe(followDirectory);
|
|
311
134
|
followDirectory();
|
|
312
135
|
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { useState, useSyncExternalStore } from "react";
|
|
2
|
+
import { Text, View } from "react-native";
|
|
3
|
+
import { useRpc, type PluginButtonContentProps, type PluginClientContext } from "@getpaseo/plugin/client";
|
|
4
|
+
import { useQuery } from "@tanstack/react-query";
|
|
5
|
+
|
|
6
|
+
import { formatCompactTokens, formatDuration, formatRelative } from "../shared/format";
|
|
7
|
+
import { contextOutlook } from "../shared/forecast";
|
|
8
|
+
import { listCompactions, setEnrolment, type CompactionRequest } from "../shared/governor";
|
|
9
|
+
import { contextStatus } from "../shared/smart-session";
|
|
10
|
+
import { profileFor } from "../shared/thresholds";
|
|
11
|
+
import type { AgentDirectory } from "./agents";
|
|
12
|
+
import { Meter, Ring, toneFill } from "./charts";
|
|
13
|
+
import { Switch, TextLink, errorText, text, useNow } from "./kit";
|
|
14
|
+
import { noteEnrolment, pillSnapshot, subscribePills } from "./pill-store";
|
|
15
|
+
import { tabular, toneColor } from "./theme";
|
|
16
|
+
|
|
17
|
+
/** What the last compaction did, in one line. */
|
|
18
|
+
function compactionLine(item: CompactionRequest | undefined, now: number): string {
|
|
19
|
+
if (item === undefined) return "None yet";
|
|
20
|
+
if (item.state === "pending" || item.state === "sending") return "Queued · sent when this turn ends";
|
|
21
|
+
if (item.state === "failed") return `Failed ${formatRelative(item.settledAt ?? item.createdAt, now)}`;
|
|
22
|
+
if (item.state === "cancelled") return `Cancelled ${formatRelative(item.settledAt ?? item.createdAt, now)}`;
|
|
23
|
+
const when = formatRelative(item.settledAt ?? item.createdAt, now);
|
|
24
|
+
return item.preTokens !== null && item.postTokens !== null ? `${when} · ${formatCompactTokens(item.preTokens)} → ${formatCompactTokens(item.postTokens)}` : when;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The pill's popover: this session's context, its compact line, task state and enrolment. */
|
|
28
|
+
export function makeContextPopover(client: PluginClientContext, agents: AgentDirectory) {
|
|
29
|
+
return function ContextPopover({ theme, layout, close, ...rest }: PluginButtonContentProps) {
|
|
30
|
+
const agentId = rest.context === "agent" ? rest.agentId : "";
|
|
31
|
+
const c = theme.colors;
|
|
32
|
+
const t = text(c);
|
|
33
|
+
const now = useNow(30_000);
|
|
34
|
+
const pills = useSyncExternalStore(subscribePills, pillSnapshot, pillSnapshot);
|
|
35
|
+
useSyncExternalStore(agents.subscribe, agents.version, agents.version);
|
|
36
|
+
const readContext = useRpc(contextStatus);
|
|
37
|
+
const readCompactions = useRpc(listCompactions);
|
|
38
|
+
const writeEnrolment = useRpc(setEnrolment);
|
|
39
|
+
const [saving, setSaving] = useState(false);
|
|
40
|
+
const [problem, setProblem] = useState<string | null>(null);
|
|
41
|
+
const detail = useQuery({ queryKey: ["smart-session", "context", agentId], queryFn: () => readContext({ agentId }), refetchInterval: 30_000 });
|
|
42
|
+
const compactions = useQuery({ queryKey: ["smart-session", "compactions", agentId], queryFn: () => readCompactions({ agentId }), refetchInterval: 30_000 });
|
|
43
|
+
|
|
44
|
+
const agent = agents.current().get(agentId);
|
|
45
|
+
const row = detail.data?.agents[0];
|
|
46
|
+
const used = agent?.usedTokens ?? row?.usedTokens ?? null;
|
|
47
|
+
const max = agent?.maxTokens ?? row?.maxTokens ?? null;
|
|
48
|
+
const enrolled = pills.enrolled.has(agentId);
|
|
49
|
+
const compact = layout.compact;
|
|
50
|
+
|
|
51
|
+
async function toggle(next: boolean) {
|
|
52
|
+
setSaving(true); setProblem(null);
|
|
53
|
+
try {
|
|
54
|
+
const { agent: saved } = await writeEnrolment({ agentId, enrolled: next });
|
|
55
|
+
noteEnrolment(saved.agentId, saved.enrolled);
|
|
56
|
+
} catch (error) {
|
|
57
|
+
setProblem(errorText(error));
|
|
58
|
+
} finally {
|
|
59
|
+
setSaving(false);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const head = (() => {
|
|
64
|
+
if (used === null || max === null) return null;
|
|
65
|
+
const compactAtPct = row?.compactAtPct ?? profileFor(max, pills.thresholds ?? undefined).compact;
|
|
66
|
+
return { compactAtPct, outlook: contextOutlook({ usedTokens: used, maxTokens: max, compactAtPct, growthTokensPerHour: row?.growthTokensPerHour ?? null }) };
|
|
67
|
+
})();
|
|
68
|
+
const sentence = head === null
|
|
69
|
+
? { line: "No context reading yet. A session reports one after its first turn.", tone: "default" as const }
|
|
70
|
+
: head.outlook.past
|
|
71
|
+
? { line: enrolled ? "Past its compact line. Smart Compact asks it at the end of a turn whether to compact." : "Past its compact line, but not enrolled, so nothing will ask it.", tone: head.outlook.tone }
|
|
72
|
+
: head.outlook.untilCompactMs !== null
|
|
73
|
+
? { line: `Reaches its compact line in about ${formatDuration(head.outlook.untilCompactMs)} at +${formatCompactTokens(row!.growthTokensPerHour!)}/h.`, tone: "default" as const }
|
|
74
|
+
: { line: "Below its compact line and not growing right now.", tone: "default" as const };
|
|
75
|
+
const latest = compactions.data?.items[0];
|
|
76
|
+
const state = row?.stateWrittenAt ?? null;
|
|
77
|
+
const facts: [string, string][] = [
|
|
78
|
+
["Task state", state === null ? (row === undefined ? "…" : "Not written yet") : `Saved ${formatRelative(state, now)}`],
|
|
79
|
+
["Last compaction", compactions.data === undefined ? "…" : compactionLine(latest, now)],
|
|
80
|
+
];
|
|
81
|
+
const ringColor = head === null ? c.foregroundMuted : head.outlook.tone !== "default" ? toneFill(c, head.outlook.tone) : enrolled ? c.foreground : c.foregroundMuted;
|
|
82
|
+
|
|
83
|
+
return <View style={{ width: compact ? "100%" : 340, padding: compact ? 16 : 14, gap: 12 }}>
|
|
84
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
|
|
85
|
+
<Ring size={40} color={ringColor} fraction={head === null ? 0 : head.outlook.pct / 100} mark={head === null ? null : head.compactAtPct / 100} />
|
|
86
|
+
<View style={{ flex: 1, gap: 1 }}>
|
|
87
|
+
<View style={{ flexDirection: "row", alignItems: "baseline", gap: 6 }}>
|
|
88
|
+
<Text style={{ color: c.foreground, fontSize: 22, lineHeight: 28, fontWeight: "600" }}>{head === null ? "—" : `${Math.round(head.outlook.pct)}%`}</Text>
|
|
89
|
+
<Text style={t.meta}>{max === null ? "context" : `of ${formatCompactTokens(max)} context`}</Text>
|
|
90
|
+
</View>
|
|
91
|
+
<Text style={t.meta}>{head === null || used === null ? "Waiting for a reading" : `${formatCompactTokens(used)} tokens · compacts at ${head.compactAtPct}% (${formatCompactTokens(head.outlook.compactTokens)})`}</Text>
|
|
92
|
+
</View>
|
|
93
|
+
</View>
|
|
94
|
+
{head === null ? null : <Meter theme={theme} pct={head.outlook.pct} tone={head.outlook.tone} mark={head.compactAtPct} height={6} label={`Context ${Math.round(head.outlook.pct)}% full, compacts at ${head.compactAtPct}%`} />}
|
|
95
|
+
<Text style={{ ...t.body, color: sentence.tone === "default" ? c.foreground : toneColor(c, sentence.tone) }}>{sentence.line}</Text>
|
|
96
|
+
<View style={{ gap: 6, paddingTop: 10, borderTopWidth: 1, borderTopColor: c.border }}>
|
|
97
|
+
{facts.map(([label, value]) => <View key={label} style={{ flexDirection: "row", gap: 12 }}>
|
|
98
|
+
<Text style={{ ...t.meta, width: 112 }}>{label}</Text>
|
|
99
|
+
<Text style={{ ...t.meta, flex: 1, color: c.foreground, ...tabular }}>{value}</Text>
|
|
100
|
+
</View>)}
|
|
101
|
+
</View>
|
|
102
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 12, paddingTop: 10, borderTopWidth: 1, borderTopColor: c.border }}>
|
|
103
|
+
<View style={{ flex: 1, gap: 2 }}>
|
|
104
|
+
<Text style={t.label}>Smart Compact for this session</Text>
|
|
105
|
+
<Text style={t.meta}>{enrolled ? `Asked to compact itself once past ${head?.compactAtPct ?? "its"}%${head === null ? " line" : ""}. Never forced.` : "Never asked to compact. The agent can still request it."}</Text>
|
|
106
|
+
</View>
|
|
107
|
+
<Switch theme={theme} label="Smart Compact for this session" hideLabel value={enrolled} disabled={saving} compact={compact} onChange={(next) => void toggle(next)} />
|
|
108
|
+
</View>
|
|
109
|
+
{problem ? <Text accessibilityRole="alert" style={{ ...t.meta, color: c.statusDanger }}>{problem}</Text> : null}
|
|
110
|
+
<TextLink theme={theme} compact={compact} label="Plan usage and every session" onPress={() => { close(); client.openScreen({ screenId: "overview" }); }} />
|
|
111
|
+
</View>;
|
|
112
|
+
};
|
|
113
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { useRpc, type PluginSurfaceProps } from "@getpaseo/plugin/client";
|
|
2
|
+
import { SettingsAction, SettingsCard, SettingsInput, SettingsRow, SettingsSection, SettingsSwitch, useToast } from "@getpaseo/plugin/client/ui";
|
|
3
|
+
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
4
|
+
import { useEffect, useState } from "react";
|
|
5
|
+
import { ActivityIndicator, ScrollView, Text, View } from "react-native";
|
|
6
|
+
|
|
7
|
+
import { formatCompactTokens } from "../shared/format";
|
|
8
|
+
import { enrolmentState } from "../shared/governor";
|
|
9
|
+
import { getSettings, installStatus, setSettings } from "../shared/smart-session";
|
|
10
|
+
import { withCompactThreshold } from "../shared/thresholds";
|
|
11
|
+
import { errorText } from "./kit";
|
|
12
|
+
import { refreshPills } from "./pill-store";
|
|
13
|
+
|
|
14
|
+
/** A whole number from 1 to 99, or null. */
|
|
15
|
+
function percent(value: string): number | null {
|
|
16
|
+
const parsed = Number(value.trim());
|
|
17
|
+
return Number.isInteger(parsed) && parsed >= 1 && parsed <= 99 ? parsed : null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Settings → Plugins → Smart Session → Smart Compact. Everything that tunes when a
|
|
22
|
+
* session is asked to compact itself; the overview keeps only the on/off switch.
|
|
23
|
+
*/
|
|
24
|
+
export function SmartCompactSettings({ theme, layout }: PluginSurfaceProps) {
|
|
25
|
+
const c = theme.colors;
|
|
26
|
+
const toast = useToast();
|
|
27
|
+
const queryClient = useQueryClient();
|
|
28
|
+
const read = useRpc(getSettings);
|
|
29
|
+
const write = useRpc(setSettings);
|
|
30
|
+
const readEnrolment = useRpc(enrolmentState);
|
|
31
|
+
const readInstall = useRpc(installStatus);
|
|
32
|
+
const query = useQuery({ queryKey: ["smart-session", "settings"], queryFn: () => read({}), refetchInterval: 60_000 });
|
|
33
|
+
const install = useQuery({ queryKey: ["smart-session", "install"], queryFn: () => readInstall({}), refetchInterval: 5 * 60_000 });
|
|
34
|
+
const [saving, setSaving] = useState(false);
|
|
35
|
+
const [draft, setDraft] = useState<{ large: string; small: string } | null>(null);
|
|
36
|
+
const [inputKey, setInputKey] = useState(0);
|
|
37
|
+
const settings = query.data?.settings;
|
|
38
|
+
useEffect(() => { setDraft(null); }, [settings?.thresholds.large.compact, settings?.thresholds.small.compact]);
|
|
39
|
+
|
|
40
|
+
if (query.isError && settings === undefined) return <View style={{ padding: 16, gap: 12 }}>
|
|
41
|
+
<Text accessibilityRole="alert" style={{ color: c.statusDanger }}>{errorText(query.error)}</Text>
|
|
42
|
+
<SettingsCard><SettingsAction label="Load the settings again" actionLabel="Retry" onPress={() => void query.refetch()} /></SettingsCard>
|
|
43
|
+
</View>;
|
|
44
|
+
if (settings === undefined) return <ActivityIndicator accessibilityLabel="Loading Smart Compact settings" color={c.foregroundMuted} style={{ padding: 24 }} />;
|
|
45
|
+
|
|
46
|
+
async function save(patch: Parameters<typeof write>[0], message: string) {
|
|
47
|
+
setSaving(true);
|
|
48
|
+
try {
|
|
49
|
+
await write(patch);
|
|
50
|
+
await refreshPills(() => readEnrolment({}));
|
|
51
|
+
await queryClient.invalidateQueries({ queryKey: ["smart-session"] });
|
|
52
|
+
toast.show(message);
|
|
53
|
+
} catch (error) {
|
|
54
|
+
toast.error(errorText(error));
|
|
55
|
+
} finally {
|
|
56
|
+
setSaving(false);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const large = draft === null ? settings.thresholds.large.compact : percent(draft.large);
|
|
61
|
+
const small = draft === null ? settings.thresholds.small.compact : percent(draft.small);
|
|
62
|
+
const changed = draft !== null && large !== null && small !== null && (large !== settings.thresholds.large.compact || small !== settings.thresholds.small.compact);
|
|
63
|
+
const off = !settings.enabled || saving;
|
|
64
|
+
const report = install.data?.report;
|
|
65
|
+
const hooksHint = report === undefined ? "Keeps this plugin's context and compaction hooks in ~/.claude/settings.json."
|
|
66
|
+
: report.error !== null ? report.error
|
|
67
|
+
: report.pluginDir === null || report.hooks.length === 0 ? "Not registered, so nothing will ask a session to compact itself."
|
|
68
|
+
: `Registered: ${report.hooks.join(", ")}. Only entries that point at this plugin are touched.`;
|
|
69
|
+
const from = formatCompactTokens(settings.thresholds.largeWindowFrom);
|
|
70
|
+
|
|
71
|
+
return <ScrollView style={{ flex: 1 }} contentContainerStyle={{ paddingVertical: 8, gap: 24 }}>
|
|
72
|
+
<SettingsSection title="Smart Compact" info="A session is never compacted unless it agrees. At the end of a turn past its line, it is asked whether to compact; it can compact, put it off with a reason, or keep working.">
|
|
73
|
+
<SettingsCard>
|
|
74
|
+
<SettingsSwitch label="Smart Compact" hint={settings.enabled ? "On. Context notices, and the question at the compact line." : "Off. Nothing is asked and nothing is compacted."}
|
|
75
|
+
value={settings.enabled} disabled={saving} onValueChange={(enabled) => void save({ enabled }, enabled ? "Smart Compact on" : "Smart Compact off")} />
|
|
76
|
+
<SettingsSwitch label="Enrol sessions automatically" hint="A session that saves its task state with the checkpoint tool is enrolled by that alone. Off, enrol each one from its pill."
|
|
77
|
+
value={settings.autoEnrol} disabled={off} onValueChange={(autoEnrol) => void save({ autoEnrol }, autoEnrol ? "Sessions enrol themselves" : "Sessions are enrolled one at a time")} />
|
|
78
|
+
<SettingsSwitch label="Context pill on every session" hint="A ring above the message box with the session's context and its compact line. Press it to enrol or remove the session."
|
|
79
|
+
value={settings.showPill} disabled={off} onValueChange={(showPill) => void save({ showPill }, showPill ? "Pill shown on every session" : "Pill hidden")} />
|
|
80
|
+
</SettingsCard>
|
|
81
|
+
</SettingsSection>
|
|
82
|
+
<SettingsSection title="When to ask" info="What wears a session down is its absolute size, not its share of the window, so large windows are asked much earlier. Earlier context notices move down with the line.">
|
|
83
|
+
<SettingsCard>
|
|
84
|
+
<SettingsInput key={`large-${inputKey}`} label={`Windows of ${from} or more (%)`} hint={`Asked at ${large ?? "—"}%, about ${large === null ? "—" : formatCompactTokens((large / 100) * 1_000_000)} tokens of a 1M window. Default 30.`}
|
|
85
|
+
initialValue={String(settings.thresholds.large.compact)} disabled={off} error={draft !== null && large === null ? "Enter a whole number from 1 to 99." : null}
|
|
86
|
+
onChangeText={(value) => setDraft((previous) => ({ large: value, small: previous?.small ?? String(settings.thresholds.small.compact) }))} />
|
|
87
|
+
<SettingsInput key={`small-${inputKey}`} label={`Smaller windows (%)`} hint={`Asked at ${small ?? "—"}% of a window under ${from}. Default 85.`}
|
|
88
|
+
initialValue={String(settings.thresholds.small.compact)} disabled={off} error={draft !== null && small === null ? "Enter a whole number from 1 to 99." : null}
|
|
89
|
+
onChangeText={(value) => setDraft((previous) => ({ small: value, large: previous?.large ?? String(settings.thresholds.large.compact) }))} />
|
|
90
|
+
{draft !== null ? <>
|
|
91
|
+
<SettingsAction label="Apply the new lines" hint={changed ? `Large ${large}% · small ${small}%` : "Nothing to apply yet"} actionLabel="Save" disabled={!changed || off}
|
|
92
|
+
onPress={() => void save({ thresholds: { ...settings.thresholds, large: withCompactThreshold(settings.thresholds.large, large!), small: withCompactThreshold(settings.thresholds.small, small!) } }, `Smart Compact asks at ${large}% and ${small}%`)} />
|
|
93
|
+
<SettingsAction label="Keep the saved lines" actionLabel="Discard" disabled={saving} onPress={() => { setDraft(null); setInputKey((k) => k + 1); }} />
|
|
94
|
+
</> : null}
|
|
95
|
+
</SettingsCard>
|
|
96
|
+
</SettingsSection>
|
|
97
|
+
<SettingsSection title="Claude Code">
|
|
98
|
+
<SettingsCard>
|
|
99
|
+
<SettingsSwitch label="Register the hooks" hint={hooksHint} value={settings.installHooks} disabled={off}
|
|
100
|
+
onValueChange={(installHooks) => void save({ installHooks }, installHooks ? "Hooks registered with Claude Code" : "Hooks removed from Claude Code")} />
|
|
101
|
+
{report?.mcp === "unavailable" ? <SettingsRow label="MCP server not registered" hint="The claude command was not reachable from the daemon." /> : null}
|
|
102
|
+
</SettingsCard>
|
|
103
|
+
</SettingsSection>
|
|
104
|
+
</ScrollView>;
|
|
105
|
+
}
|