paseo-smart-session 3.1.0 → 3.3.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 +34 -23
- package/client/chat.tsx +2 -2
- package/client/commands.ts +62 -0
- package/client/display.tsx +9 -62
- package/client/guard/index.tsx +11 -42
- package/client/guard/paused.tsx +91 -120
- package/client/guard/status.ts +113 -0
- package/client/guard/timeline.tsx +61 -56
- package/client/overview.tsx +59 -52
- package/client/pill-store.ts +8 -2
- package/client/pill-view.ts +61 -0
- package/client/pill.tsx +50 -47
- package/client/popover.tsx +147 -31
- package/client/screens.ts +6 -0
- package/client/settings.tsx +223 -83
- package/docs/media/before-after.png +0 -0
- package/docs/media/smart-session-guard.png +0 -0
- package/docs/media/smart-session-mobile.png +0 -0
- package/docs/media/smart-session-pill.png +0 -0
- package/docs/media/smart-session-settings.png +0 -0
- package/docs/navigation-verification.md +4 -4
- package/docs/session-guard.md +15 -17
- package/index.client.tsx +19 -34
- package/package.json +1 -1
- package/paseo-plugin.json +1 -0
- package/server/guard/engine.ts +27 -3
- package/server/guard/index.ts +35 -11
- package/shared/guard/limit-notice.ts +87 -0
- package/shared/guard/presentation.ts +66 -11
- package/shared/guard/timeline.ts +3 -3
- package/shared/guard/timing.ts +37 -0
- package/shared/guard/zerosub.ts +92 -10
- package/client/guard/pill-icons.tsx +0 -26
- package/client/guard/pills.ts +0 -132
- package/client/guard/settings.tsx +0 -114
- package/client/guard/surfaces.ts +0 -3
- package/docs/screenshots/display-settings-desktop.png +0 -0
- package/docs/screenshots/display-settings-mobile.png +0 -0
package/client/overview.tsx
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { useRpc, useSettings, type PluginClientContext, type PluginScreenProps } from "@getpaseo/plugin/client";
|
|
2
2
|
import { Icon, useToast } from "@getpaseo/plugin/client/ui";
|
|
3
3
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
4
|
-
import { useEffect, type ReactNode } from "react";
|
|
5
|
-
import { Platform, ScrollView, Text, View } from "react-native";
|
|
4
|
+
import { useEffect, useRef, useSyncExternalStore, type ReactNode } from "react";
|
|
5
|
+
import { Platform, ScrollView, Text, View, type ScrollView as ScrollViewType } from "react-native";
|
|
6
6
|
|
|
7
7
|
import { formatCompactTokens, formatDay, formatDuration, formatRelative, isInteresting } from "../shared/format";
|
|
8
8
|
import {
|
|
@@ -10,20 +10,22 @@ import {
|
|
|
10
10
|
type Tone, type WindowForecast,
|
|
11
11
|
} from "../shared/forecast";
|
|
12
12
|
import { enrolmentState } from "../shared/governor";
|
|
13
|
-
import { guardPreferences
|
|
14
|
-
import { governorNotice, pausedSessions } from "../shared/guard/presentation";
|
|
13
|
+
import { guardPreferences } from "../shared/guard/contracts";
|
|
14
|
+
import { governorNotice, pausedLead, pausedSessions } from "../shared/guard/presentation";
|
|
15
15
|
import { budgetStatus, contextStatus, getSettings, installStatus, setSettings, spendSummary } from "../shared/smart-session";
|
|
16
16
|
import { DailyBars, HourHeatmap, Meter, PastWindows, RankedTotals, Ring, WindowChart, ink, toneFill } from "./charts";
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
17
|
+
import { Exceptions, PausedSection } from "./guard/paused";
|
|
18
|
+
import { guardSnapshot, refreshGuard, subscribeGuard } from "./guard/status";
|
|
19
19
|
import { Card, CardRow, Chip, IconButton, SectionHead, StatusGlyph, Switch, TextLink, errorText, text, useNow } from "./kit";
|
|
20
20
|
import type { Live } from "./live";
|
|
21
21
|
import { Reveal, Skeleton } from "./motion";
|
|
22
22
|
import { refreshPills } from "./pill-store";
|
|
23
|
+
import { SETTINGS_SCREEN } from "./screens";
|
|
23
24
|
import { tabular, toneColor, type Theme } from "./theme";
|
|
24
25
|
|
|
25
|
-
export
|
|
26
|
-
|
|
26
|
+
export { OVERVIEW_SCREEN, PAUSED_SCREEN } from "./screens";
|
|
27
|
+
/** The one settings screen; it kept Smart Compact's id so links to it still land. */
|
|
28
|
+
export const SMART_COMPACT_SETTINGS = SETTINGS_SCREEN;
|
|
27
29
|
|
|
28
30
|
const ORDER = (id: string) => (id === "five_hour" ? 0 : id === "seven_day" ? 1 : 2);
|
|
29
31
|
|
|
@@ -106,7 +108,7 @@ function FeatureRow({ theme, icon, title, line, tone = "default", on, onToggle,
|
|
|
106
108
|
}
|
|
107
109
|
|
|
108
110
|
export function makeOverview(client: PluginClientContext, live: Live) {
|
|
109
|
-
return function SmartSessionOverview({ theme, layout, navigation }: PluginScreenProps) {
|
|
111
|
+
return function SmartSessionOverview({ theme, layout, navigation, host, params }: PluginScreenProps) {
|
|
110
112
|
const c = theme.colors;
|
|
111
113
|
const t = text(c);
|
|
112
114
|
const compact = layout.compact;
|
|
@@ -120,7 +122,6 @@ export function makeOverview(client: PluginClientContext, live: Live) {
|
|
|
120
122
|
const writeSettings = useRpc(setSettings);
|
|
121
123
|
const readEnrolment = useRpc(enrolmentState);
|
|
122
124
|
const readInstall = useRpc(installStatus);
|
|
123
|
-
const readGuard = useRpc(guardStatus);
|
|
124
125
|
const guardSettings = useSettings(guardPreferences);
|
|
125
126
|
|
|
126
127
|
// The recorder writes once a minute and the daemon caches usage for five, so
|
|
@@ -133,20 +134,31 @@ export function makeOverview(client: PluginClientContext, live: Live) {
|
|
|
133
134
|
const enrolmentQuery = useQuery({ queryKey: ["smart-session", "enrolment"], queryFn: () => readEnrolment({}), refetchInterval: 30_000 });
|
|
134
135
|
// Cheap after the first call: with nothing to reconcile it reads two files and writes none.
|
|
135
136
|
const installQuery = useQuery({ queryKey: ["smart-session", "install"], queryFn: () => readInstall({}), refetchInterval: 5 * 60_000 });
|
|
136
|
-
|
|
137
|
+
// The pill's own status poll; this screen only reads it.
|
|
138
|
+
const guard = useSyncExternalStore(subscribeGuard, guardSnapshot, guardSnapshot) ?? undefined;
|
|
139
|
+
const focus = params?.focus === "paused" ? "paused" : null;
|
|
140
|
+
const scroller = useRef<ScrollViewType>(null);
|
|
141
|
+
const pausedY = useRef<number | null>(null);
|
|
142
|
+
// "Paused sessions" from anywhere lands on the list, however long the page above it.
|
|
143
|
+
useEffect(() => {
|
|
144
|
+
// Only when the list starts below the first screenful; otherwise the lead stays in view.
|
|
145
|
+
if (focus === "paused" && pausedY.current !== null && pausedY.current > 400) scroller.current?.scrollTo({ y: Math.max(0, pausedY.current - 12), animated: false });
|
|
146
|
+
}, [focus, guard !== undefined]);
|
|
137
147
|
|
|
138
148
|
useEffect(() => { if (budgetQuery.data) live.setBudget(budgetQuery.data); }, [budgetQuery.data]);
|
|
139
149
|
const guardValues = guardSettings.status === "ready" ? guardSettings.values : undefined;
|
|
140
150
|
// A change made here or on another client moves the pills without waiting out their poll.
|
|
141
|
-
useEffect(() => { if (guardValues !== undefined)
|
|
151
|
+
useEffect(() => { if (guardValues !== undefined) refreshGuard(); }, [guardValues]);
|
|
142
152
|
|
|
143
153
|
const settings = settingsQuery.data?.settings;
|
|
144
154
|
const forecasts = (budgetQuery.data?.windows ?? []).filter(isInteresting).map((w) => forecastWindow(w, now)).sort((a, b) => ORDER(a.id) - ORDER(b.id) || b.pct - a.pct);
|
|
145
155
|
const lead = leadWindow(forecasts);
|
|
146
156
|
const head = budgetQuery.data === undefined ? null : headline(lead, now);
|
|
147
157
|
const session = budgetQuery.data?.windows.find((w) => w.id === "five_hour");
|
|
148
|
-
const guard = guardQuery.data;
|
|
149
158
|
const paused = guard === undefined ? [] : pausedSessions(guard.pauses);
|
|
159
|
+
// The lead speaks only for pauses this process can act on; another governor's are listed, not announced.
|
|
160
|
+
const governed = guard?.governor === "active" ? paused : [];
|
|
161
|
+
const pauseLead = pausedLead(governed, guard?.policy.enabled === true && guard.policy.autoResume, now, (ms) => formatMoment(new Date(ms).toISOString(), now));
|
|
150
162
|
const enrolled = new Set((enrolmentQuery.data?.agents ?? []).filter((a) => a.enrolled).map((a) => a.agentId));
|
|
151
163
|
const agents = (contextQuery.data?.agents ?? []).filter((a) => a.status !== "closed");
|
|
152
164
|
const recorder = budgetQuery.data?.recorder;
|
|
@@ -154,7 +166,7 @@ export function makeOverview(client: PluginClientContext, live: Live) {
|
|
|
154
166
|
const error = budgetQuery.data?.error ?? contextQuery.data?.error ?? (budgetQuery.error ? errorText(budgetQuery.error) : null);
|
|
155
167
|
const guardOn = guardSettings.status === "ready" ? guardSettings.values.enabled : null;
|
|
156
168
|
const guardPct = guardOn && guard?.governor === "active" ? guard.policy.thresholdPct : null;
|
|
157
|
-
const
|
|
169
|
+
const titleOf = (agentId: string) => (contextQuery.data?.agents.find((a) => a.agentId === agentId)?.title ?? paused.find((p) => p.agentId === agentId)?.title)?.trim() || `Session ${agentId.slice(0, 8)}`;
|
|
158
170
|
|
|
159
171
|
async function saveSmartCompact(patch: Parameters<typeof writeSettings>[0], message: string) {
|
|
160
172
|
try {
|
|
@@ -171,9 +183,9 @@ export function makeOverview(client: PluginClientContext, live: Live) {
|
|
|
171
183
|
function toggleGuard(next: boolean) {
|
|
172
184
|
if (guardSettings.status !== "ready") return;
|
|
173
185
|
void guardSettings.save({ ...guardSettings.values, enabled: next }, guardSettings.revision).then((saved) => {
|
|
174
|
-
if (!saved) return toast.error("
|
|
175
|
-
toast.show(next ? "
|
|
176
|
-
|
|
186
|
+
if (!saved) return toast.error("That change couldn't be saved. Try again.");
|
|
187
|
+
toast.show(next ? "Sessions pause at the limit" : "Pause at the limit off");
|
|
188
|
+
refreshGuard();
|
|
177
189
|
});
|
|
178
190
|
}
|
|
179
191
|
|
|
@@ -190,17 +202,13 @@ export function makeOverview(client: PluginClientContext, live: Live) {
|
|
|
190
202
|
// Only the clause that is state wears the state colour.
|
|
191
203
|
: pastLine > 0 ? <>{`${enrolled.size} enrolled · `}<Text style={{ color: c.statusWarning }}>{`${pastLine} past ${pastLine === 1 ? "its" : "their"} compact line, asked at the end of a turn`}</Text></>
|
|
192
204
|
: `${enrolled.size} enrolled · asked past ${settings.thresholds.large.compact}% of a ${formatCompactTokens(settings.thresholds.largeWindowFrom)}+ window, ${settings.thresholds.small.compact}% of a smaller one`;
|
|
193
|
-
const waitingResets = paused.filter((p) => p.phase !== "attention").map((p) => (p.anchorResetsAt ? Date.parse(p.anchorResetsAt) : NaN)).filter((t) => Number.isFinite(t) && t > now).sort((a, b) => a - b);
|
|
194
205
|
const guardState: { line: string; tone: Tone } = (() => {
|
|
195
206
|
if (guardOn === null) return { line: "…", tone: "default" };
|
|
196
|
-
if (!guardOn) return { line: paused.length > 0 ? `Off · ${paused.length} paused still ${paused.length === 1 ? "waits" : "wait"} for the reset` : "Off ·
|
|
207
|
+
if (!guardOn) return { line: paused.length > 0 ? `Off · ${paused.length} paused still ${paused.length === 1 ? "waits" : "wait"} for the reset` : "Off · sessions run into the limit", tone: "default" };
|
|
197
208
|
if (notice !== null && guard?.governor !== "off") return { line: notice.title, tone: "default" };
|
|
198
209
|
const auto = guard?.policy.autoResume !== false;
|
|
199
|
-
|
|
200
|
-
if (paused.length > 0) {
|
|
201
|
-
const at = waitingResets[0];
|
|
202
|
-
return { line: at === undefined ? `${paused.length} paused · waiting for a fresh reading` : `${paused.length} paused · ${auto ? `resume${paused.length === 1 ? "s" : ""} at` : "continue by hand after"} ${formatMoment(new Date(at).toISOString(), now)}`, tone: "warning" };
|
|
203
|
-
}
|
|
210
|
+
// Paused sessions have their own section and the lead; this line keeps to the rule.
|
|
211
|
+
if (paused.length > 0) return { line: `${paused.length} paused · listed above`, tone: "default" };
|
|
204
212
|
const threshold = guard?.policy.thresholdPct ?? 100;
|
|
205
213
|
const s5 = lead?.id === "five_hour" ? lead : null;
|
|
206
214
|
if (s5 && s5.pct >= threshold) {
|
|
@@ -215,26 +223,35 @@ export function makeOverview(client: PluginClientContext, live: Live) {
|
|
|
215
223
|
return { line: `Pauses at ${threshold}% of the session window · ${auto ? "resumes by itself" : "you continue"} after the reset`, tone: "default" };
|
|
216
224
|
})();
|
|
217
225
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
226
|
+
// While something is paused here, that is the news; the plan forecast moves to the line below.
|
|
227
|
+
const title = pauseLead !== null ? pauseLead.title : head?.title ?? null;
|
|
228
|
+
const autoResume = guard?.policy.enabled === true && guard.policy.autoResume;
|
|
229
|
+
const detail = pauseLead !== null ? [head?.title, pauseLead.shared !== null ? `in ${formatDuration(pauseLead.shared - now)}` : null, autoResume ? "each continues in its own conversation" : "continue each by hand"].filter(Boolean).join(" · ") : head?.detail ?? null;
|
|
230
|
+
const glyph = pauseLead !== null ? { icon: pauseLead.tone === "danger" ? "TriangleAlert" : "Pause", color: toneColor(c, pauseLead.tone) }
|
|
231
|
+
: head ? { icon: toneIcon(head.tone, lead?.state ?? null), color: head.tone === "default" ? c.foregroundMuted : toneColor(c, head.tone) } : null;
|
|
232
|
+
const header = <View style={{ flexDirection: "row", alignItems: "flex-start", gap: 10 }}>
|
|
233
|
+
{glyph ? <View style={{ paddingTop: compact ? 3 : 5 }}><Icon name={glyph.icon} size={compact ? 17 : 19} color={glyph.color} /></View> : null}
|
|
234
|
+
<View style={{ flex: 1, gap: 4 }}>
|
|
235
|
+
{title ? <Text accessibilityRole="header" style={{ color: c.foreground, fontSize: compact ? 19 : 22, lineHeight: compact ? 25 : 29, fontWeight: "600" }}>{title}</Text>
|
|
236
|
+
: <Skeleton width="60%" height={22} color={c.surface2} />}
|
|
237
|
+
{detail ? <Text style={{ color: c.foregroundMuted, fontSize: 13, lineHeight: 19, ...tabular }}>{detail}</Text> : null}
|
|
227
238
|
</View>
|
|
228
|
-
|
|
229
|
-
<Chip theme={theme} icon="Pause" label={`${paused.length} paused`} color={paused.some((p) => p.phase === "attention") ? c.statusDanger : c.statusWarning} />
|
|
230
|
-
<TextLink theme={theme} label={paused.some((p) => p.phase === "attention") ? "Check the paused sessions" : "Session Guard resumes them after the reset"} onPress={openPaused} />
|
|
231
|
-
</View> : null}
|
|
239
|
+
<IconButton theme={theme} icon="Settings" label="Smart Session settings" compact={compact} onPress={() => client.openSettings(SMART_COMPACT_SETTINGS)} />
|
|
232
240
|
</View>;
|
|
241
|
+
const showPaused = paused.length > 0 || focus === "paused";
|
|
242
|
+
const pausedSection = showPaused ? <View onLayout={(event) => {
|
|
243
|
+
const first = pausedY.current === null;
|
|
244
|
+
pausedY.current = event.nativeEvent.layout.y;
|
|
245
|
+
if (first && focus === "paused" && pausedY.current > 400) scroller.current?.scrollTo({ y: Math.max(0, pausedY.current - 12), animated: false });
|
|
246
|
+
}}>
|
|
247
|
+
{guard === undefined ? <LoadingCard theme={theme} rows={1} /> : <PausedSection theme={theme} layout={layout} host={host} navigation={navigation} status={guard} shared={pauseLead?.shared ?? null} now={now} openSettings={() => client.openSettings(SMART_COMPACT_SETTINGS)} />}
|
|
248
|
+
</View> : null;
|
|
233
249
|
|
|
234
|
-
return <ScrollView style={{ flex: 1, backgroundColor: c.surface0 }} contentContainerStyle={{ padding: compact ? 16 : 32, paddingBottom: 48 }}>
|
|
250
|
+
return <ScrollView ref={scroller} style={{ flex: 1, backgroundColor: c.surface0 }} contentContainerStyle={{ padding: compact ? 16 : 32, paddingBottom: 48 }}>
|
|
235
251
|
<View style={{ width: "100%", maxWidth: 860, alignSelf: "center", gap: compact ? 20 : 26 }}>
|
|
236
252
|
{header}
|
|
237
253
|
{error ? <Text accessibilityRole="alert" style={{ ...t.meta, color: c.statusDanger }}>{error}</Text> : null}
|
|
254
|
+
{pausedSection}
|
|
238
255
|
|
|
239
256
|
<View style={{ gap: 8 }}>
|
|
240
257
|
<SectionHead theme={theme} title="Plan limits" />
|
|
@@ -271,6 +288,7 @@ export function makeOverview(client: PluginClientContext, live: Live) {
|
|
|
271
288
|
<StatusGlyph theme={theme} status={agent.status} />
|
|
272
289
|
<Text numberOfLines={1} style={{ ...t.label, flexShrink: 1 }}>{agent.title ?? `Session ${agent.agentId.slice(0, 8)}`}</Text>
|
|
273
290
|
<View style={{ flex: 1 }} />
|
|
291
|
+
{governed.some((p) => p.agentId === agent.agentId) ? <Chip theme={theme} icon="Pause" label="Paused" color={c.statusWarning} /> : null}
|
|
274
292
|
{on ? <Chip theme={theme} label="Smart Compact" /> : compact ? null : <Text style={t.small}>Not enrolled</Text>}
|
|
275
293
|
<Ring size={16} color={outlook.tone !== "default" ? toneFill(c, outlook.tone) : on ? c.foreground : c.foregroundMuted} fraction={outlook.pct / 100} mark={on ? agent.compactAtPct / 100 : null} />
|
|
276
294
|
<Text style={{ color: c.foreground, fontSize: 15, lineHeight: 20, fontWeight: "600", minWidth: 36, textAlign: "right", ...tabular }}>{`${Math.round(outlook.pct)}%`}</Text>
|
|
@@ -293,20 +311,9 @@ export function makeOverview(client: PluginClientContext, live: Live) {
|
|
|
293
311
|
<Text style={{ ...t.meta, flex: 1, color: c.statusWarning }}>{hooksProblem}</Text>
|
|
294
312
|
</View> : null}
|
|
295
313
|
</FeatureRow>
|
|
296
|
-
<FeatureRow theme={theme} first={false} icon=
|
|
297
|
-
links={<
|
|
298
|
-
|
|
299
|
-
<TextLink theme={theme} compact={compact} label="Settings" onPress={() => client.openSettings("session-guard")} />
|
|
300
|
-
</View>}>
|
|
301
|
-
{paused.length > 0 ? <View style={{ gap: 2, paddingLeft: 25 }}>{paused.slice(0, 3).map((pause) => {
|
|
302
|
-
const at = pause.anchorResetsAt ? Date.parse(pause.anchorResetsAt) : NaN;
|
|
303
|
-
// The line above already names the first reset; a row repeats a time only when its own differs.
|
|
304
|
-
const own = pause.phase === "attention" ? "Check" : Number.isFinite(at) && at > now && Math.abs(at - (waitingResets[0] ?? at)) >= 60_000 ? formatMoment(pause.anchorResetsAt!, now) : null;
|
|
305
|
-
return <View key={pause.id} style={{ flexDirection: "row", alignItems: "baseline", gap: 8 }}>
|
|
306
|
-
<Text numberOfLines={1} style={{ ...t.meta, flex: 1, color: c.foreground }}>{pause.title?.trim() || "Untitled session"}</Text>
|
|
307
|
-
{own ? <Text style={{ ...t.meta, color: pause.phase === "attention" ? c.statusDanger : c.foreground, fontWeight: "600" }}>{own}</Text> : null}
|
|
308
|
-
</View>;
|
|
309
|
-
})}</View> : null}
|
|
314
|
+
<FeatureRow theme={theme} first={false} icon="CirclePause" title="Pause at the limit" line={guardState.line} tone={guardState.tone} on={guardOn} compact={compact} disabled={guardSettings.saving} onToggle={toggleGuard}
|
|
315
|
+
links={<TextLink theme={theme} compact={compact} label="Settings" onPress={() => client.openSettings(SMART_COMPACT_SETTINGS)} />}>
|
|
316
|
+
{guard !== undefined && guardOn ? <Exceptions theme={theme} layout={layout} status={guard} titleOf={titleOf} /> : null}
|
|
310
317
|
</FeatureRow>
|
|
311
318
|
</Card>
|
|
312
319
|
|
package/client/pill-store.ts
CHANGED
|
@@ -9,8 +9,12 @@ import type { EnrolmentState } from "../shared/governor";
|
|
|
9
9
|
import type { Thresholds } from "../shared/thresholds";
|
|
10
10
|
|
|
11
11
|
export interface PillState {
|
|
12
|
-
/** Both switches folded into one answer: is
|
|
12
|
+
/** Both switches folded into one answer: is Smart Compact's part of the pill wanted. */
|
|
13
13
|
readonly showPill: boolean;
|
|
14
|
+
/** Smart Compact's master switch, on its own. */
|
|
15
|
+
readonly compactOn: boolean;
|
|
16
|
+
/** The "pill on every session" preference, on its own; it also covers protection. */
|
|
17
|
+
readonly pillOn: boolean;
|
|
14
18
|
readonly enrolled: ReadonlySet<string>;
|
|
15
19
|
/** The compact bands; null until the daemon has answered. */
|
|
16
20
|
readonly thresholds: Thresholds | null;
|
|
@@ -22,7 +26,7 @@ export interface PillState {
|
|
|
22
26
|
* The alternative — assume shown, then retract — flashes a pill at everyone who
|
|
23
27
|
* turned it off, every time the app loads.
|
|
24
28
|
*/
|
|
25
|
-
let current: PillState = { showPill: false, enrolled: new Set(), thresholds: null };
|
|
29
|
+
let current: PillState = { showPill: false, compactOn: false, pillOn: false, enrolled: new Set(), thresholds: null };
|
|
26
30
|
|
|
27
31
|
/** The current answer, for code outside React. */
|
|
28
32
|
export function state(): PillState {
|
|
@@ -70,6 +74,8 @@ export async function refreshPills(fetch: () => Promise<EnrolmentState>): Promis
|
|
|
70
74
|
const next = await fetch();
|
|
71
75
|
publish({
|
|
72
76
|
showPill: next.enabled && next.showPill,
|
|
77
|
+
compactOn: next.enabled,
|
|
78
|
+
pillOn: next.showPill,
|
|
73
79
|
enrolled: enrolmentSet(next.agents),
|
|
74
80
|
thresholds: next.thresholds ?? current.thresholds,
|
|
75
81
|
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the one Smart Session pill says, as plain functions: whether a session gets
|
|
3
|
+
* one, and its label and title. Kept apart from `pill.tsx` so tests can run them.
|
|
4
|
+
*/
|
|
5
|
+
import { formatClock } from "../shared/format.ts";
|
|
6
|
+
import { contextOutlook } from "../shared/forecast.ts";
|
|
7
|
+
import { pausePill } from "../shared/guard/presentation.ts";
|
|
8
|
+
import { profileFor, type Thresholds } from "../shared/thresholds.ts";
|
|
9
|
+
import type { DirectoryAgent } from "./agents.ts";
|
|
10
|
+
import { governedPause, sessionProtection, type GuardStatus } from "./guard/status.ts";
|
|
11
|
+
import type { PillState } from "./pill-store.ts";
|
|
12
|
+
|
|
13
|
+
/** Everything the ring shows about one agent's context, for the icon, label and title. */
|
|
14
|
+
export function pillPresentation(agent: Pick<DirectoryAgent, "usedTokens" | "maxTokens"> | undefined, enrolled: boolean, thresholds: Thresholds | null, compactOn = true) {
|
|
15
|
+
const known = agent !== undefined && agent.usedTokens !== null && agent.maxTokens !== null;
|
|
16
|
+
const compactWords = !compactOn ? "Smart Compact off" : enrolled ? null : "Smart Compact off for this session";
|
|
17
|
+
if (!known) {
|
|
18
|
+
return {
|
|
19
|
+
label: compactOn ? "Smart Compact" : "Context",
|
|
20
|
+
title: `${compactWords ?? "Smart Compact on"} · no context reading yet`,
|
|
21
|
+
fraction: 0, mark: null as number | null, tone: "default" as const, pct: null as number | null,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
const compactAtPct = profileFor(agent.maxTokens!, thresholds ?? undefined).compact;
|
|
25
|
+
const outlook = contextOutlook({ usedTokens: agent.usedTokens!, maxTokens: agent.maxTokens!, compactAtPct });
|
|
26
|
+
const pct = Math.round(outlook.pct);
|
|
27
|
+
return {
|
|
28
|
+
label: `${pct}%`,
|
|
29
|
+
title: `Context ${pct}% full · ${compactWords ?? `Smart Compact asks at ${compactAtPct}%`}`,
|
|
30
|
+
fraction: outlook.pct / 100, mark: compactAtPct / 100, tone: outlook.tone, pct,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Whether protection runs from this process, so the pill has something to say about it. */
|
|
35
|
+
function governing(status: GuardStatus | null): boolean {
|
|
36
|
+
return status !== null && status.governor === "active" && status.policy.enabled;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Whether one session gets a pill. A pause governed here always does; otherwise the
|
|
41
|
+
* pill follows its preference and needs at least one of its features on.
|
|
42
|
+
*/
|
|
43
|
+
export function pillVisible(agentId: string, pills: Pick<PillState, "compactOn" | "pillOn">, status: GuardStatus | null, displayOn: boolean): boolean {
|
|
44
|
+
if (governedPause(status, agentId) !== undefined) return true;
|
|
45
|
+
return displayOn && pills.pillOn && (pills.compactOn || governing(status));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The pill's label and title: the pause when there is one, else the context. */
|
|
49
|
+
export function describePill(agentId: string, agent: Pick<DirectoryAgent, "usedTokens" | "maxTokens"> | undefined, pills: Pick<PillState, "enrolled" | "thresholds" | "compactOn">, status: GuardStatus | null, now = Date.now()) {
|
|
50
|
+
const pause = governedPause(status, agentId);
|
|
51
|
+
if (pause !== undefined && status !== null) {
|
|
52
|
+
const view = pausePill(pause, status.policy, now, (iso) => formatClock(iso));
|
|
53
|
+
return { label: view.label, title: view.title };
|
|
54
|
+
}
|
|
55
|
+
const view = pillPresentation(agent, pills.enrolled.has(agentId), pills.thresholds, pills.compactOn);
|
|
56
|
+
const protection = sessionProtection(status, agentId);
|
|
57
|
+
// Only the exception earns words: a protected session says nothing about it.
|
|
58
|
+
const title = protection.kind === "on" && !protection.protected ? `${view.title} · not paused at the limit` : view.title;
|
|
59
|
+
return { label: view.label, title };
|
|
60
|
+
}
|
|
61
|
+
|
package/client/pill.tsx
CHANGED
|
@@ -1,68 +1,60 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The Smart
|
|
2
|
+
* The Smart Session pill, on every agent's composer: the one place Smart Session
|
|
3
|
+
* shows up next to the box you are about to type into.
|
|
3
4
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* Normally it answers how full this session's context is and whether Smart Compact
|
|
6
|
+
* will ask it to compact itself: the icon is a ring that fills with the context and
|
|
7
|
+
* carries a tick at the compact line, the label is the percentage. Protection adds
|
|
8
|
+
* nothing while it simply protects; the quiet case stays quiet. When this session is
|
|
9
|
+
* paused at the session limit the pill turns into the pause itself, an amber pause
|
|
10
|
+
* glyph and the moment it resumes, and it stays on the composer even when the pill
|
|
11
|
+
* is otherwise hidden, so a paused session can always be released from where it is.
|
|
12
|
+
* Pressing it opens `client/popover.tsx`, which has the detail and both switches.
|
|
9
13
|
*
|
|
10
|
-
* State lives in
|
|
11
|
-
*
|
|
12
|
-
* settings) are
|
|
13
|
-
*
|
|
14
|
-
* directory, which already carries each agent's last usage, so the ring moves with
|
|
14
|
+
* State lives in module stores (`pill-store.ts`, `guard/status.ts`) rather than in
|
|
15
|
+
* each pill, because the pill and everything that changes it (its popover, the
|
|
16
|
+
* Command Center item, the overview and settings) are in this one client bundle.
|
|
17
|
+
* Context occupancy comes from the shared agent directory, so the ring moves with
|
|
15
18
|
* every turn without a poll of its own.
|
|
16
19
|
*
|
|
17
|
-
*
|
|
20
|
+
* Registered on the host's own client rather than through the Display gate, because
|
|
21
|
+
* a paused session's pill must outlive "hide the pill".
|
|
18
22
|
*/
|
|
19
23
|
|
|
20
24
|
import type { PluginButtonIconProps, PluginClientContext, PluginButtonRegistration } from "@getpaseo/plugin/client";
|
|
25
|
+
import { Icon } from "@getpaseo/plugin/client/ui";
|
|
21
26
|
import { useSyncExternalStore } from "react";
|
|
22
27
|
|
|
23
|
-
import { contextOutlook } from "../shared/forecast";
|
|
24
28
|
import { enrolmentState } from "../shared/governor";
|
|
29
|
+
import type { AgentDirectory } from "./agents";
|
|
30
|
+
import { Ring, toneFill } from "./charts";
|
|
31
|
+
import { subscribeDisplay } from "./display";
|
|
32
|
+
import { governedPause, guardSnapshot, subscribeGuard } from "./guard/status";
|
|
33
|
+
import { describePill, pillPresentation, pillVisible } from "./pill-view";
|
|
25
34
|
import { pillSnapshot, refreshPills, state as currentState, subscribePills } from "./pill-store";
|
|
35
|
+
import { makeContextPopover } from "./popover";
|
|
26
36
|
|
|
27
37
|
export { refreshPills } from "./pill-store";
|
|
28
|
-
|
|
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";
|
|
38
|
+
export { describePill, pillPresentation, pillVisible } from "./pill-view";
|
|
33
39
|
|
|
34
40
|
/** How often the client re-reads enrolment, for changes made somewhere else. */
|
|
35
41
|
const REFRESH_MS = 20_000;
|
|
36
42
|
|
|
37
|
-
/**
|
|
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,
|
|
54
|
-
};
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/** One live icon per pill: the ring, muted while the session is not enrolled. */
|
|
43
|
+
/** One live icon per pill: the pause while paused, otherwise the ring, muted while not enrolled. */
|
|
58
44
|
function makeIcon(agents: AgentDirectory) {
|
|
59
|
-
return function
|
|
45
|
+
return function SmartSessionPillIcon(props: PluginButtonIconProps) {
|
|
60
46
|
const current = useSyncExternalStore(subscribePills, pillSnapshot, pillSnapshot);
|
|
47
|
+
const status = useSyncExternalStore(subscribeGuard, guardSnapshot, guardSnapshot);
|
|
61
48
|
useSyncExternalStore(agents.subscribe, agents.version, agents.version);
|
|
62
49
|
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
50
|
const c = props.theme.colors;
|
|
51
|
+
const pause = governedPause(status, agentId);
|
|
52
|
+
if (pause !== undefined) {
|
|
53
|
+
const stuck = pause.phase === "attention";
|
|
54
|
+
return <Icon name={stuck ? "TriangleAlert" : "Pause"} size={props.size} color={stuck ? c.statusDanger : c.statusWarning} />;
|
|
55
|
+
}
|
|
56
|
+
const enrolled = current.compactOn && current.enrolled.has(agentId);
|
|
57
|
+
const view = pillPresentation(agents.current().get(agentId), enrolled, current.thresholds, current.compactOn);
|
|
66
58
|
const color = view.tone !== "default" ? toneFill(c, view.tone) : enrolled ? c.foreground : c.foregroundMuted;
|
|
67
59
|
return <Ring size={props.size} color={color} fraction={view.fraction} mark={enrolled ? view.mark : null} />;
|
|
68
60
|
};
|
|
@@ -73,10 +65,11 @@ export function contributeClient(client: PluginClientContext, agents: AgentDirec
|
|
|
73
65
|
const tracked = new Map<string, { workspaceId: string; handle: PluginButtonRegistration | null; signature: string }>();
|
|
74
66
|
const Icon = makeIcon(agents);
|
|
75
67
|
const Content = makeContextPopover(client, agents);
|
|
68
|
+
// The Display switch; shown until the saved preference says otherwise, like the old gate.
|
|
69
|
+
let displayOn = true;
|
|
76
70
|
|
|
77
71
|
function describe(agentId: string) {
|
|
78
|
-
|
|
79
|
-
return { label: view.label, title: view.title };
|
|
72
|
+
return describePill(agentId, agents.current().get(agentId), currentState(), guardSnapshot());
|
|
80
73
|
}
|
|
81
74
|
|
|
82
75
|
function register(agentId: string, entry: { workspaceId: string; handle: PluginButtonRegistration | null; signature: string }): void {
|
|
@@ -90,11 +83,13 @@ export function contributeClient(client: PluginClientContext, agents: AgentDirec
|
|
|
90
83
|
});
|
|
91
84
|
}
|
|
92
85
|
|
|
93
|
-
/** Brings the registrations in line with the
|
|
86
|
+
/** Brings the registrations in line with the stores, whichever way they moved. */
|
|
94
87
|
function sync(): void {
|
|
88
|
+
const status = guardSnapshot();
|
|
95
89
|
for (const [agentId, entry] of tracked) {
|
|
96
|
-
|
|
97
|
-
|
|
90
|
+
const visible = !agents.current().get(agentId)?.closed && pillVisible(agentId, currentState(), status, displayOn);
|
|
91
|
+
if (visible && entry.handle === null) register(agentId, entry);
|
|
92
|
+
else if (!visible && entry.handle !== null) {
|
|
98
93
|
entry.handle.remove();
|
|
99
94
|
entry.handle = null;
|
|
100
95
|
} else if (entry.handle !== null) {
|
|
@@ -130,6 +125,12 @@ export function contributeClient(client: PluginClientContext, agents: AgentDirec
|
|
|
130
125
|
const refresh = () => refreshPills(() => client.rpc(enrolmentState, {}));
|
|
131
126
|
|
|
132
127
|
const unsubscribeStore = subscribePills(sync);
|
|
128
|
+
const unsubscribeGuard = subscribeGuard(sync);
|
|
129
|
+
const unsubscribeDisplay = subscribeDisplay((values) => {
|
|
130
|
+
if (values.enable_composer_button === displayOn) return;
|
|
131
|
+
displayOn = values.enable_composer_button;
|
|
132
|
+
sync();
|
|
133
|
+
});
|
|
133
134
|
const unsubscribeAgents = agents.subscribe(followDirectory);
|
|
134
135
|
followDirectory();
|
|
135
136
|
|
|
@@ -139,6 +140,8 @@ export function contributeClient(client: PluginClientContext, agents: AgentDirec
|
|
|
139
140
|
return () => {
|
|
140
141
|
clearInterval(timer);
|
|
141
142
|
unsubscribeAgents();
|
|
143
|
+
unsubscribeDisplay();
|
|
144
|
+
unsubscribeGuard();
|
|
142
145
|
unsubscribeStore();
|
|
143
146
|
for (const agentId of [...tracked.keys()]) forget(agentId);
|
|
144
147
|
};
|