paseo-smart-session 2.1.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +40 -11
  2. package/client/agents.ts +26 -4
  3. package/client/charts.tsx +277 -152
  4. package/client/chat.tsx +122 -0
  5. package/client/display.tsx +55 -33
  6. package/client/guard/index.tsx +14 -16
  7. package/client/guard/paused.tsx +125 -38
  8. package/client/guard/pill-icons.tsx +1 -1
  9. package/client/guard/pills.ts +16 -6
  10. package/client/guard/surfaces.ts +3 -2
  11. package/client/guard/timeline.tsx +116 -56
  12. package/client/kit.tsx +144 -0
  13. package/client/live.ts +49 -0
  14. package/client/motion.tsx +94 -0
  15. package/client/overview.tsx +373 -0
  16. package/client/pill-store.ts +77 -0
  17. package/client/pill.tsx +83 -260
  18. package/client/popover.tsx +113 -0
  19. package/client/settings.tsx +105 -0
  20. package/client/theme.ts +56 -0
  21. package/docs/media/smart-session-chat.png +0 -0
  22. package/docs/media/smart-session-flow.gif +0 -0
  23. package/docs/media/smart-session-guard.png +0 -0
  24. package/docs/media/smart-session-hero-light.png +0 -0
  25. package/docs/media/smart-session-hero.png +0 -0
  26. package/docs/media/smart-session-mobile.png +0 -0
  27. package/docs/media/smart-session-pill.png +0 -0
  28. package/docs/navigation-verification.md +6 -6
  29. package/docs/screenshots/display-settings-desktop.png +0 -0
  30. package/docs/screenshots/display-settings-mobile.png +0 -0
  31. package/docs/session-guard.md +3 -3
  32. package/index.client.tsx +47 -35
  33. package/index.server.ts +14 -5
  34. package/mcp.mjs +1 -1
  35. package/package.json +1 -1
  36. package/paseo-plugin.json +10 -0
  37. package/server/settings.ts +10 -0
  38. package/shared/blocks.ts +15 -0
  39. package/shared/chat.ts +174 -0
  40. package/shared/forecast.ts +233 -0
  41. package/shared/format.ts +47 -1
  42. package/shared/governor.ts +3 -0
  43. package/shared/smart-session.ts +16 -0
  44. package/client/navigation.tsx +0 -14
  45. package/client/surface.tsx +0 -659
  46. package/docs/screenshots/smart-compact-handoff.png +0 -0
package/client/pill.tsx CHANGED
@@ -1,297 +1,116 @@
1
1
  /**
2
- * The smart-compact pill, on every agent's composer.
2
+ * The Smart Compact pill, on every agent's composer.
3
3
  *
4
- * Enrolment is a file on disk, which makes it invisible: there was no way to look
5
- * at a session and tell whether the governor was watching it. The pill is that
6
- * answer, in the one place the answer matters — next to the box you are about to
7
- * type into — and pressing it changes it.
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 press, the Command Center item, the surface
11
- * toggle) are all in this one client bundle. One store, one fetch, and every
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
- * Two states, on or off, and pressing it moves between them. There is no third
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, PluginHostProps } from "@getpaseo/plugin/client";
21
- import { Icon } from "@getpaseo/plugin/client/react-native";
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 { smartCompactLabel } from "../shared/format";
26
- import { enrolmentState, setEnrolment, type EnrolmentState } from "../shared/governor";
27
- import type { AgentDirectory } from "./agents";
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
- /** Pointer dwell before the tooltip opens, so passing over the pill is quiet. */
33
- const HOVER_DELAY_MS = 300;
34
-
35
- /**
36
- * The host's own pill chrome, mirrored from Paseo's `composerPillStyles`:
37
- * `spacing[3]` and `spacing[1]` of padding inside a 1px border.
38
- *
39
- * A plugin renders *inside* that padding, so a hover region left at its natural
40
- * size answers only over the glyph and stays dead across most of the pill the user
41
- * is actually pointing at. Cancelling the padding with an equal negative margin
42
- * grows the region to the pill's edge and leaves the layout exactly where it was —
43
- * the pill's measured size is unchanged, since the two cancel.
44
- */
45
- const HOST_PILL_INSET = { horizontal: 13, vertical: 5 };
46
-
47
- interface PillState {
48
- /** Both switches folded into one answer: is there a pill on the composer at all. */
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
- function snapshot(): PillState {
71
- return state;
72
- }
73
-
74
- function publish(next: PillState): void {
75
- state = next;
76
- for (const listener of listeners) listener();
77
- }
78
-
79
- function enrolmentSet(agents: EnrolmentState["agents"]): ReadonlySet<string> {
80
- return new Set(agents.filter((agent) => agent.enrolled).map((agent) => agent.agentId));
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: PillHandle | null }>();
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 register(agentId: string, entry: { workspaceId: string; handle: PillHandle | null }): void {
228
- const toggle = async () => {
229
- const { agent } = await client.rpc(setEnrolment, {
230
- agentId,
231
- enrolled: !snapshot().enrolled.has(agentId),
232
- });
233
- const enrolled = new Set(snapshot().enrolled);
234
- if (agent.enrolled) enrolled.add(agent.agentId);
235
- else enrolled.delete(agent.agentId);
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 (state.showPill && entry.handle === null) register(agentId, entry);
274
- else if (!state.showPill && entry.handle !== null) {
275
- removePill(entry.handle);
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
- updatePill(entry.handle, smartCompactLabel({ enrolled: state.enrolled.has(agentId) }));
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
- const handle = tracked.get(agentId)?.handle;
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) track(agentId, agent.workspaceId);
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 = subscribe(sync);
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
+ }