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.
Files changed (38) hide show
  1. package/README.md +34 -23
  2. package/client/chat.tsx +2 -2
  3. package/client/commands.ts +62 -0
  4. package/client/display.tsx +9 -62
  5. package/client/guard/index.tsx +11 -42
  6. package/client/guard/paused.tsx +91 -120
  7. package/client/guard/status.ts +113 -0
  8. package/client/guard/timeline.tsx +61 -56
  9. package/client/overview.tsx +59 -52
  10. package/client/pill-store.ts +8 -2
  11. package/client/pill-view.ts +61 -0
  12. package/client/pill.tsx +50 -47
  13. package/client/popover.tsx +147 -31
  14. package/client/screens.ts +6 -0
  15. package/client/settings.tsx +223 -83
  16. package/docs/media/before-after.png +0 -0
  17. package/docs/media/smart-session-guard.png +0 -0
  18. package/docs/media/smart-session-mobile.png +0 -0
  19. package/docs/media/smart-session-pill.png +0 -0
  20. package/docs/media/smart-session-settings.png +0 -0
  21. package/docs/navigation-verification.md +4 -4
  22. package/docs/session-guard.md +15 -17
  23. package/index.client.tsx +19 -34
  24. package/package.json +1 -1
  25. package/paseo-plugin.json +1 -0
  26. package/server/guard/engine.ts +27 -3
  27. package/server/guard/index.ts +35 -11
  28. package/shared/guard/limit-notice.ts +87 -0
  29. package/shared/guard/presentation.ts +66 -11
  30. package/shared/guard/timeline.ts +3 -3
  31. package/shared/guard/timing.ts +37 -0
  32. package/shared/guard/zerosub.ts +92 -10
  33. package/client/guard/pill-icons.tsx +0 -26
  34. package/client/guard/pills.ts +0 -132
  35. package/client/guard/settings.tsx +0 -114
  36. package/client/guard/surfaces.ts +0 -3
  37. package/docs/screenshots/display-settings-desktop.png +0 -0
  38. package/docs/screenshots/display-settings-mobile.png +0 -0
@@ -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, statusRpc as guardStatus } from "../shared/guard/contracts";
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 { refreshGuardPills } from "./guard/pills";
18
- import { GUARD_SCREENS } from "./guard/surfaces";
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 const OVERVIEW_SCREEN = "overview";
26
- export const SMART_COMPACT_SETTINGS = "smart-compact";
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
- const guardQuery = useQuery({ queryKey: ["smart-session", "guard"], queryFn: () => readGuard({}), refetchInterval: 15_000 });
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) refreshGuardPills(); }, [guardValues]);
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 openPaused = () => client.openScreen({ screenId: GUARD_SCREENS.paused });
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("Session Guard could not be changed. Try again.");
175
- toast.show(next ? "Session Guard on" : "Session Guard off");
176
- void guardQuery.refetch();
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 · nothing pauses at the session limit", tone: "default" };
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
- if (paused.some((p) => p.phase === "attention")) return { line: `${paused.length} paused · ${paused.filter((p) => p.phase === "attention").length} need${paused.filter((p) => p.phase === "attention").length === 1 ? "s" : ""} a look`, tone: "danger" };
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
- const header = <View style={{ gap: 6 }}>
219
- <View style={{ flexDirection: "row", alignItems: "flex-start", gap: 10 }}>
220
- {head ? <View style={{ paddingTop: compact ? 3 : 5 }}><Icon name={toneIcon(head.tone, lead?.state ?? null)} size={compact ? 17 : 19} color={head.tone === "default" ? c.foregroundMuted : toneColor(c, head.tone)} /></View> : null}
221
- <View style={{ flex: 1, gap: 4 }}>
222
- {head ? <Text accessibilityRole="header" style={{ color: c.foreground, fontSize: compact ? 19 : 22, lineHeight: compact ? 25 : 29, fontWeight: "600" }}>{head.title}</Text>
223
- : <Skeleton width="60%" height={22} color={c.surface2} />}
224
- {head ? <Text style={{ color: c.foregroundMuted, fontSize: 13, lineHeight: 19, ...tabular }}>{head.detail}</Text> : null}
225
- </View>
226
- <IconButton theme={theme} icon="Settings" label="Smart Session settings" compact={compact} onPress={() => client.openSettings(SMART_COMPACT_SETTINGS)} />
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
- {paused.length > 0 && guard?.governor === "active" ? <View style={{ flexDirection: "row", alignItems: "center", flexWrap: "wrap", gap: 8, paddingLeft: compact ? 27 : 29 }}>
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={guardOn ? "ShieldCheck" : "ShieldOff"} title="Session Guard" line={guardState.line} tone={guardState.tone} on={guardOn} compact={compact} disabled={guardSettings.saving} onToggle={toggleGuard}
297
- links={<View style={{ flexDirection: "row", gap: 14 }}>
298
- {paused.length > 0 || guardOn ? <TextLink theme={theme} compact={compact} label="Paused sessions" onPress={openPaused} /> : null}
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
 
@@ -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 there a pill on the composer at all. */
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 Compact pill, on every agent's composer.
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
- * 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.
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 this module rather than in each pill, because the pill and the
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
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
- * With Smart Compact off there is no pill at all: there is nothing for one to say.
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
- 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";
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
- /** 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,
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 ContextRingIcon(props: PluginButtonIconProps) {
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
- const view = pillPresentation(agents.current().get(agentId), currentState().enrolled.has(agentId), currentState().thresholds);
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 store, whichever way it moved. */
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
- if (currentState().showPill && entry.handle === null) register(agentId, entry);
97
- else if (!currentState().showPill && entry.handle !== null) {
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
  };