paseo-smart-session 2.0.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 (66) hide show
  1. package/README.md +55 -130
  2. package/assets/LICENSE.lucide +43 -0
  3. package/assets/icon.png +0 -0
  4. package/assets/icon.svg +1 -0
  5. package/client/agents.ts +200 -0
  6. package/client/charts.tsx +277 -152
  7. package/client/chat.tsx +122 -0
  8. package/client/display.tsx +55 -33
  9. package/client/guard/index.tsx +48 -0
  10. package/client/guard/paused.tsx +164 -0
  11. package/client/guard/pill-icons.tsx +26 -0
  12. package/client/guard/pills.ts +132 -0
  13. package/client/guard/settings.tsx +114 -0
  14. package/client/guard/surfaces.ts +3 -0
  15. package/client/guard/timeline.tsx +154 -0
  16. package/client/kit.tsx +144 -0
  17. package/client/live.ts +49 -0
  18. package/client/motion.tsx +94 -0
  19. package/client/overview.tsx +373 -0
  20. package/client/pill-store.ts +77 -0
  21. package/client/pill.tsx +91 -380
  22. package/client/popover.tsx +113 -0
  23. package/client/settings.tsx +105 -0
  24. package/client/theme.ts +56 -0
  25. package/docs/media/smart-session-chat.png +0 -0
  26. package/docs/media/smart-session-flow.gif +0 -0
  27. package/docs/media/smart-session-guard.png +0 -0
  28. package/docs/media/smart-session-hero-light.png +0 -0
  29. package/docs/media/smart-session-hero.png +0 -0
  30. package/docs/media/smart-session-mobile.png +0 -0
  31. package/docs/media/smart-session-pill.png +0 -0
  32. package/docs/navigation-verification.md +6 -6
  33. package/docs/screenshots/display-settings-desktop.png +0 -0
  34. package/docs/screenshots/display-settings-mobile.png +0 -0
  35. package/docs/session-guard.md +88 -0
  36. package/index.client.tsx +52 -35
  37. package/index.server.ts +19 -11
  38. package/mcp.mjs +22 -3
  39. package/package.json +2 -1
  40. package/paseo-plugin.json +11 -0
  41. package/server/guard/client.ts +27 -0
  42. package/server/guard/engine.ts +554 -0
  43. package/server/guard/index.ts +395 -0
  44. package/server/guard/labels.ts +77 -0
  45. package/server/guard/processes.ts +147 -0
  46. package/server/guard/standalone.ts +61 -0
  47. package/server/guard/store.ts +113 -0
  48. package/server/guard/timeline.ts +24 -0
  49. package/server/guard/usage.ts +57 -0
  50. package/server/recorder.ts +2 -0
  51. package/server/settings.ts +10 -0
  52. package/server/store.ts +25 -1
  53. package/shared/blocks.ts +15 -0
  54. package/shared/chat.ts +174 -0
  55. package/shared/forecast.ts +233 -0
  56. package/shared/format.ts +47 -1
  57. package/shared/governor.ts +3 -0
  58. package/shared/guard/contracts.ts +65 -0
  59. package/shared/guard/policy.ts +128 -0
  60. package/shared/guard/presentation.ts +85 -0
  61. package/shared/guard/timeline.ts +34 -0
  62. package/shared/lifecycle.ts +18 -0
  63. package/shared/smart-session.ts +16 -0
  64. package/client/navigation.tsx +0 -14
  65. package/client/surface.tsx +0 -574
  66. package/docs/screenshots/smart-compact-handoff.png +0 -0
package/client/charts.tsx CHANGED
@@ -1,185 +1,310 @@
1
- import type { PluginSurfaceProps } from "@getpaseo/plugin/client";
2
- import React from "react";
3
- import { Text, View } from "react-native";
1
+ import { useState, type ReactNode } from "react";
2
+ import { Platform, Text, View } from "react-native";
3
+ import { Icon } from "@getpaseo/plugin/client/ui";
4
4
 
5
- import { formatTokens, rampStep } from "../shared/format";
6
-
7
- type Theme = PluginSurfaceProps["theme"];
5
+ import { formatClock, formatCompactTokens, formatDay, rampStep } from "../shared/format";
6
+ import { contextRing, formatMoment, type Tone, type WindowForecast } from "../shared/forecast";
7
+ import { isLight, tabular, withAlpha, type Colors, type Theme } from "./theme";
8
8
 
9
9
  /**
10
- * A sequential ramp: one hue, light to dark.
10
+ * Every chart and meter on the overview, drawn with Views (plugins get no SVG
11
+ * library; the host `Icon` draws square inline SVG only, used for the ring).
11
12
  *
12
- * Magnitude gets a single hue at stepped intensities — never a rainbow, and never
13
- * the status colours, which are reserved for state. The host gives one accent hue
14
- * and no scale, so the steps come from opacity over the chart surface, which is the
15
- * same construction as a lightness ramp for a single hue.
13
+ * One rule for colour, from the dataviz method: magnitude is neutral ink, one hue at
14
+ * stepped opacity, so a chart reads the same in light and dark. Amber and red are
15
+ * status and appear only where a forecast says a limit will bite or did.
16
16
  */
17
- const STEPS = [0.12, 0.3, 0.5, 0.72, 1] as const;
18
17
 
19
- const WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
18
+ const HOUR = 3_600_000;
19
+
20
+ /** Ink: the foreground at a given strength, a touch stronger on light grounds. */
21
+ export function ink(colors: Colors, strength: number): string {
22
+ return withAlpha(colors.foreground, Math.min(1, strength * (isLight(colors) ? 1.1 : 1)));
23
+ }
24
+
25
+ /** The fill for a value in a given state. */
26
+ export function toneFill(colors: Colors, tone: Tone): string {
27
+ return tone === "danger" ? colors.statusDanger : tone === "warning" ? colors.statusWarning : ink(colors, 0.82);
28
+ }
29
+
30
+ const hoverable = Platform.OS === "web";
31
+
32
+ /** Pointer handlers for a hover readout; inert on touch platforms. */
33
+ function hover(set: (value: number | null) => void, index: number) {
34
+ return hoverable ? { onPointerEnter: () => set(index), onPointerLeave: () => set(null) } : {};
35
+ }
20
36
 
21
37
  /**
22
- * Tokens by weekday and hour of day, in local time.
23
- *
24
- * The question this answers is "when am I actually working the model hardest", so
25
- * the cell is the unit and the ramp carries the magnitude. Hour labels are shown
26
- * every six hours: a label on all 24 would be unreadable at this width.
38
+ * A meter: the used share, an optional faint projection of where it lands at the
39
+ * reset, an optional muted pace tick (where an even spend would be) and an optional
40
+ * mark (the compact line on a context bar).
27
41
  */
28
- export function HourHeatmap({
29
- theme,
30
- cells,
31
- compact,
32
- }: {
33
- theme: Theme;
34
- cells: readonly { weekday: number; hour: number; tokens: number }[];
35
- compact: boolean;
42
+ export function Meter({ theme, pct, tone = "default", projected, pace, mark, height = 8, label }: {
43
+ theme: Theme; pct: number; tone?: Tone; projected?: number | null; pace?: number | null; mark?: number | null; height?: number; label: string;
36
44
  }) {
37
- const grid = new Map<string, number>();
38
- let max = 0;
39
- for (const cell of cells) {
40
- grid.set(`${cell.weekday}|${cell.hour}`, cell.tokens);
41
- if (cell.tokens > max) max = cell.tokens;
45
+ const c = theme.colors;
46
+ const used = Math.max(0, Math.min(100, pct));
47
+ const fill = toneFill(c, tone);
48
+ const ahead = projected === null || projected === undefined ? 0 : Math.max(0, Math.min(100, projected) - used);
49
+ const tick = (at: number, color: string, key: string) => (
50
+ <View key={key} style={{ position: "absolute", left: `${Math.max(0, Math.min(100, at))}%`, top: -3, bottom: -3, width: 2, marginLeft: -1, borderRadius: 1, backgroundColor: color }} />
51
+ );
52
+ return <View accessibilityRole="progressbar" accessibilityLabel={label} accessibilityValue={{ min: 0, max: 100, now: Math.round(used) }}
53
+ style={{ height, justifyContent: "center", marginVertical: 3 }}>
54
+ <View style={{ height, borderRadius: height / 2, backgroundColor: ink(c, 0.1), overflow: "hidden", flexDirection: "row" }}>
55
+ <View style={{ width: `${used}%`, backgroundColor: fill, borderTopRightRadius: height / 2, borderBottomRightRadius: height / 2 }} />
56
+ {ahead > 0 ? <View style={{ width: `${ahead}%`, backgroundColor: withAlpha(tone === "default" ? c.foreground : fill, tone === "default" ? 0.16 : 0.24), marginLeft: -height / 2, paddingLeft: height / 2 }} /> : null}
57
+ </View>
58
+ {pace !== null && pace !== undefined ? tick(pace, c.foregroundMuted, "pace") : null}
59
+ {mark !== null && mark !== undefined ? tick(mark, c.foreground, "mark") : null}
60
+ </View>;
61
+ }
62
+
63
+ /** The context ring glyph, the same object as the composer pill's icon. */
64
+ export function Ring({ size, color, fraction, mark }: { size: number; color: string; fraction: number; mark: number | null }) {
65
+ // A stronger track at pill size, so the ring reads as a gauge and not as the host's spinner.
66
+ return <Icon icon={{ svg: contextRing(fraction, mark, size <= 18 ? 0.5 : 0.28) }} size={size} color={color} />;
67
+ }
68
+
69
+ /** The percentage held at `t`: readings only climb within a window, so the last one before it stands. */
70
+ function heldAt(history: readonly { t: string; pct: number }[], t: number): number | null {
71
+ let value: number | null = null;
72
+ for (const point of history) {
73
+ if (Date.parse(point.t) > t) break;
74
+ value = point.pct;
42
75
  }
76
+ return value;
77
+ }
43
78
 
44
- const size = compact ? 9 : 12;
45
- const gap = 2;
79
+ /** A small axis caption. */
80
+ function Axis({ theme, children, align = "left" }: { theme: Theme; children: ReactNode; align?: "left" | "right" | "center" }) {
81
+ return <Text numberOfLines={1} style={{ color: theme.colors.foregroundMuted, fontSize: 10, lineHeight: 14, textAlign: align, ...tabular }}>{children}</Text>;
82
+ }
46
83
 
47
- return (
48
- <View style={{ gap: 6 }}>
49
- <View style={{ gap }}>
50
- {WEEKDAYS.map((label, weekday) => (
51
- <View key={label} style={{ flexDirection: "row", alignItems: "center", gap }}>
52
- <Text style={{ color: theme.colors.foregroundMuted, fontSize: 10, width: 28 }}>{label}</Text>
53
- {Array.from({ length: 24 }, (unused, hour) => {
54
- const tokens = grid.get(`${weekday}|${hour}`) ?? 0;
55
- const step = rampStep(tokens, max, STEPS.length);
56
- return (
57
- <View
58
- key={hour}
59
- style={{
60
- width: size,
61
- height: size,
62
- borderRadius: 2,
63
- // An empty hour is the surface, not a faint accent: absence and
64
- // "a little" must not look alike.
65
- backgroundColor: step === -1 ? theme.colors.surface2 : theme.colors.accent,
66
- opacity: step === -1 ? 0.35 : STEPS[step],
67
- }}
68
- />
69
- );
70
- })}
71
- </View>
72
- ))}
73
- </View>
74
- <View style={{ flexDirection: "row", gap, marginLeft: 28 + gap }}>
75
- {Array.from({ length: 24 }, (unused, hour) => (
76
- <View key={hour} style={{ width: size, alignItems: "center" }}>
77
- <Text style={{ color: theme.colors.foregroundMuted, fontSize: 9 }}>
78
- {hour % 6 === 0 ? String(hour) : ""}
79
- </Text>
84
+ /**
85
+ * The open session window, start to reset: what was recorded (ink), where the
86
+ * current pace takes it (faint), and the stretch with nothing left (amber) when it
87
+ * runs out first. A hairline at 100%, Session Guard's threshold when it sits lower,
88
+ * and a "now" rule.
89
+ */
90
+ export function WindowChart({ theme, forecast, history, now, compact, guardPct, hour12 }: {
91
+ theme: Theme; forecast: WindowForecast; history: readonly { t: string; pct: number }[]; now: number; compact: boolean; guardPct?: number | null; hour12?: boolean;
92
+ }) {
93
+ const c = theme.colors;
94
+ const [hovered, setHovered] = useState<number | null>(null);
95
+ if (forecast.startAt === null || forecast.resetsAt === null) return null;
96
+ const start = Date.parse(forecast.startAt);
97
+ const end = Date.parse(forecast.resetsAt);
98
+ const columns = compact ? 40 : 60;
99
+ const slot = (end - start) / columns;
100
+ const height = compact ? 60 : 76;
101
+ const runsOut = forecast.runsOutAt === null ? null : Date.parse(forecast.runsOutAt);
102
+ const bars = Array.from({ length: columns }, (unused, i) => {
103
+ const from = start + i * slot;
104
+ const t = from + slot;
105
+ if (from <= now) {
106
+ const value = heldAt(history, Math.min(t, now)) ?? 0;
107
+ const current = t >= now;
108
+ return { t: Math.min(t, now), value, kind: current ? ("now" as const) : ("past" as const) };
109
+ }
110
+ const rate = forecast.ratePerHour ?? 0;
111
+ const value = Math.min(100, forecast.pct + (rate * (from + slot / 2 - now)) / HOUR);
112
+ const out = runsOut !== null && from + slot / 2 >= runsOut;
113
+ return { t: from + slot / 2, value: out ? 100 : value, kind: out ? ("out" as const) : ("ahead" as const) };
114
+ });
115
+ const elapsed = Math.max(0, Math.min(1, (now - start) / (end - start)));
116
+ const colorOf = (kind: (typeof bars)[number]["kind"]) =>
117
+ kind === "now" ? toneFill(c, forecast.tone === "danger" ? "danger" : "default")
118
+ : kind === "past" ? ink(c, 0.42)
119
+ : kind === "out" ? withAlpha(c.statusWarning, 0.42)
120
+ : ink(c, 0.13);
121
+ const readout = hovered === null ? null : bars[hovered]!;
122
+ const caption = readout === null
123
+ ? null
124
+ : `${formatClock(new Date(readout.t).toISOString(), hour12)} · ${readout.kind === "past" || readout.kind === "now" ? `${Math.round(readout.value)}%` : readout.kind === "out" ? "nothing left" : `~${Math.round(readout.value)}% at this pace`}`;
125
+ const showGuard = guardPct !== null && guardPct !== undefined && guardPct < 100;
126
+ const summary = `Session window from ${formatClock(forecast.startAt, hour12)} to ${formatClock(forecast.resetsAt, hour12)}: ${Math.round(forecast.pct)}% used now${forecast.runsOutAt ? `, runs out around ${formatClock(forecast.runsOutAt, hour12)} at this pace` : ""}.`;
127
+ return <View accessible accessibilityLabel={summary} style={{ gap: 4 }}>
128
+ <View style={{ flexDirection: "row", alignItems: "center", minHeight: 14 }}>
129
+ <View style={{ flex: 1 }}>{caption ? <Text style={{ color: c.foreground, fontSize: 11, lineHeight: 14, ...tabular }}>{caption}</Text> : <Axis theme={theme}>This window</Axis>}</View>
130
+ <Axis theme={theme} align="right">100%</Axis>
131
+ </View>
132
+ <View style={{ height, position: "relative" }}>
133
+ <View style={{ position: "absolute", left: 0, right: 0, top: 0, height: 1, backgroundColor: c.border }} />
134
+ <View style={{ position: "absolute", left: 0, right: 0, top: height / 2, height: 1, backgroundColor: withAlpha(c.border, 0.6) }} />
135
+ {showGuard ? <View style={{ position: "absolute", left: 0, right: 0, top: height * (1 - guardPct! / 100), height: 0, borderTopWidth: 1, borderStyle: "dashed", borderColor: c.foregroundMuted }} /> : null}
136
+ <View style={{ position: "absolute", left: 0, right: 0, bottom: 0, top: 0, flexDirection: "row", alignItems: "flex-end", gap: compact ? 1 : 2 }}>
137
+ {bars.map((bar, i) => (
138
+ <View key={i} {...hover(setHovered, i)} style={{ flex: 1, height: "100%", justifyContent: "flex-end" }}>
139
+ <View style={{ height: Math.max(bar.value > 0 ? 2 : 1, (bar.value / 100) * height), backgroundColor: bar.value > 0 ? colorOf(bar.kind) : withAlpha(c.border, 0.8), borderTopLeftRadius: 2, borderTopRightRadius: 2, opacity: hovered === null || hovered === i ? 1 : 0.7 }} />
80
140
  </View>
81
141
  ))}
82
142
  </View>
83
- <Text style={{ color: theme.colors.foregroundMuted, fontSize: 11 }}>
84
- Local time. Darkest cell is {formatTokens(max)} tokens.
85
- </Text>
143
+ <View style={{ position: "absolute", top: -2, bottom: 0, left: `${elapsed * 100}%`, width: 1, backgroundColor: c.foreground }} />
144
+ <View style={{ position: "absolute", left: 0, right: 0, bottom: 0, height: 1, backgroundColor: c.border }} />
86
145
  </View>
87
- );
146
+ <View style={{ height: 14, position: "relative" }}>
147
+ <View style={{ position: "absolute", left: 0 }}><Axis theme={theme}>{formatClock(forecast.startAt, hour12)}</Axis></View>
148
+ {elapsed > 0.14 && elapsed < 0.86 ? <View style={{ position: "absolute", left: `${elapsed * 100}%`, width: 60, marginLeft: -30 }}><Axis theme={theme} align="center">now</Axis></View> : null}
149
+ <View style={{ position: "absolute", right: 0 }}><Axis theme={theme} align="right">{`resets ${formatClock(forecast.resetsAt, hour12)}`}</Axis></View>
150
+ </View>
151
+ </View>;
88
152
  }
89
153
 
90
154
  /**
91
- * Tokens per day.
92
- *
93
- * One series, so no legend — the heading names it — and one label, on the peak,
94
- * rather than a number over every bar.
155
+ * Finished session windows over the last 14 days, placed on a calendar: each is
156
+ * a column at its start time, as tall as it got. Red where it hit the limit,
157
+ * amber past 90%.
95
158
  */
96
- export function DailyBars({
97
- theme,
98
- days,
99
- compact,
100
- }: {
101
- theme: Theme;
102
- days: readonly { day: string; tokens: number }[];
103
- compact: boolean;
159
+ export function PastWindows({ theme, past, now, compact, hour12 }: {
160
+ theme: Theme; past: readonly { startAt: string | null; firstSeenAt: string; peakPct: number }[]; now: number; compact: boolean; hour12?: boolean;
104
161
  }) {
162
+ const c = theme.colors;
163
+ const [hovered, setHovered] = useState<number | null>(null);
164
+ const span = 14 * 24 * HOUR;
165
+ const from = now - span;
166
+ const items = past
167
+ .map((w) => ({ at: Date.parse(w.startAt ?? w.firstSeenAt), peak: w.peakPct }))
168
+ .filter((w) => Number.isFinite(w.at) && w.at >= from);
169
+ if (items.length === 0) return null;
170
+ const height = compact ? 36 : 44;
171
+ const width = compact ? 4 : 6;
172
+ const hits = items.filter((w) => w.peak >= 99.5).length;
173
+ // Red only for a window that reached the limit, matching the caption; the rest is ink.
174
+ const color = (peak: number) => (peak >= 99.5 ? c.statusDanger : ink(c, 0.42));
175
+ const readout = hovered === null ? null : items[hovered]!;
176
+ const caption = readout === null
177
+ ? `${items.length} windows · ${hits === 0 ? "none reached the limit" : `${hits} reached the limit`}`
178
+ : `${formatMoment(new Date(readout.at).toISOString(), now, hour12)}${new Date(readout.at).toDateString() === new Date(now).toDateString() ? "" : `, ${formatDay(new Date(readout.at).toISOString())}`} · peaked at ${Math.round(readout.peak)}%`;
179
+ const days = Array.from({ length: 15 }, (unused, i) => {
180
+ const day = new Date(from + i * 24 * HOUR); day.setHours(0, 0, 0, 0);
181
+ return day.getTime();
182
+ }).filter((t) => t > from);
183
+ return <View accessible accessibilityLabel={`Session windows in the last 14 days: ${items.length}, ${hits} reached the limit.`} style={{ gap: 4 }}>
184
+ <View style={{ flexDirection: "row", alignItems: "center", minHeight: 14, gap: 8 }}>
185
+ <Text style={{ flex: 1, color: readout ? c.foreground : c.foregroundMuted, fontSize: 11, lineHeight: 14, ...tabular }}>{caption}</Text>
186
+ <Axis theme={theme} align="right">100%</Axis>
187
+ </View>
188
+ <View style={{ height, position: "relative" }}>
189
+ <View style={{ position: "absolute", left: 0, right: 0, top: 0, height: 1, backgroundColor: c.border }} />
190
+ {days.map((t) => <View key={t} style={{ position: "absolute", top: 0, bottom: 0, left: `${((t - from) / span) * 100}%`, width: 1, backgroundColor: withAlpha(c.border, 0.5) }} />)}
191
+ {items.map((w, i) => (
192
+ <View key={i} {...hover(setHovered, i)} style={{ position: "absolute", bottom: 0, left: `${((w.at - from) / span) * 100}%`, width: width + 6, marginLeft: -3, height, justifyContent: "flex-end", alignItems: "center" }}>
193
+ <View style={{ width, height: Math.max(2, (w.peak / 100) * height), backgroundColor: color(w.peak), borderTopLeftRadius: 2, borderTopRightRadius: 2, opacity: hovered === null || hovered === i ? 1 : 0.6 }} />
194
+ </View>
195
+ ))}
196
+ <View style={{ position: "absolute", left: 0, right: 0, bottom: 0, height: 1, backgroundColor: c.border }} />
197
+ </View>
198
+ <View style={{ flexDirection: "row", justifyContent: "space-between" }}>
199
+ <Axis theme={theme}>{formatDay(new Date(from).toISOString())}</Axis>
200
+ <Axis theme={theme} align="right">today</Axis>
201
+ </View>
202
+ </View>;
203
+ }
204
+
205
+ /** Tokens per day: one ink series, the peak day labelled, everything else on hover. */
206
+ export function DailyBars({ theme, days, compact }: { theme: Theme; days: readonly { day: string; tokens: number }[]; compact: boolean }) {
207
+ const c = theme.colors;
208
+ const [hovered, setHovered] = useState<number | null>(null);
105
209
  if (days.length === 0) return null;
106
210
  const max = days.reduce((peak, point) => Math.max(peak, point.tokens), 0);
107
- const height = compact ? 48 : 64;
108
- const peakDay = days.reduce((best, point) => (point.tokens > best.tokens ? point : best), days[0]!);
109
-
110
- return (
111
- <View style={{ gap: 6 }}>
112
- <View style={{ flexDirection: "row", alignItems: "flex-end", gap: 2, height }}>
113
- {days.map((point) => (
114
- <View
115
- key={point.day}
116
- style={{
117
- flex: 1,
118
- height: max === 0 ? 1 : Math.max(2, (point.tokens / max) * height),
119
- backgroundColor: theme.colors.accent,
120
- opacity: point.day === peakDay.day ? 1 : 0.65,
121
- // Rounded at the data end only; the baseline end stays square so the
122
- // bar reads as anchored rather than floating.
123
- borderTopLeftRadius: 3,
124
- borderTopRightRadius: 3,
125
- }}
126
- />
211
+ const height = compact ? 56 : 72;
212
+ const peakIndex = days.findIndex((point) => point.tokens === max);
213
+ const readout = hovered === null ? null : days[hovered]!;
214
+ const dayOf = (day: string) => formatDay(`${day}T12:00:00`);
215
+ return <View accessible accessibilityLabel={`Tokens per day over ${days.length} days; the busiest was ${dayOf(days[peakIndex]!.day)} with ${formatCompactTokens(max)}.`} style={{ gap: 4 }}>
216
+ <View style={{ flexDirection: "row", alignItems: "center", minHeight: 14 }}>
217
+ <Text style={{ flex: 1, color: readout ? c.foreground : c.foregroundMuted, fontSize: 11, lineHeight: 14, ...tabular }}>
218
+ {readout ? `${dayOf(readout.day)} · ${formatCompactTokens(readout.tokens)} tokens` : `Busiest: ${dayOf(days[peakIndex]!.day)} · ${formatCompactTokens(max)}`}
219
+ </Text>
220
+ <Axis theme={theme} align="right">{formatCompactTokens(max)}</Axis>
221
+ </View>
222
+ <View style={{ height, position: "relative" }}>
223
+ <View style={{ position: "absolute", left: 0, right: 0, top: 0, height: 1, backgroundColor: withAlpha(c.border, 0.6) }} />
224
+ <View style={{ position: "absolute", left: 0, right: 0, top: 0, bottom: 0, flexDirection: "row", alignItems: "flex-end", gap: 2 }}>
225
+ {days.map((point, i) => (
226
+ <View key={point.day} {...hover(setHovered, i)} style={{ flex: 1, height: "100%", justifyContent: "flex-end", alignItems: "center" }}>
227
+ <View style={{ width: "100%", maxWidth: 24, height: max === 0 ? 1 : Math.max(point.tokens > 0 ? 2 : 1, (point.tokens / max) * height), backgroundColor: point.tokens === 0 ? withAlpha(c.border, 0.8) : ink(c, i === peakIndex || i === hovered ? 0.82 : 0.42), borderTopLeftRadius: 3, borderTopRightRadius: 3 }} />
228
+ </View>
127
229
  ))}
128
230
  </View>
129
- <View style={{ flexDirection: "row", justifyContent: "space-between" }}>
130
- <Text style={{ color: theme.colors.foregroundMuted, fontSize: 10 }}>{days[0]!.day.slice(5)}</Text>
131
- <Text style={{ color: theme.colors.foregroundMuted, fontSize: 10 }}>
132
- peak {peakDay.day.slice(5)} · {formatTokens(peakDay.tokens)}
133
- </Text>
134
- <Text style={{ color: theme.colors.foregroundMuted, fontSize: 10 }}>
135
- {days[days.length - 1]!.day.slice(5)}
136
- </Text>
137
- </View>
231
+ <View style={{ position: "absolute", left: 0, right: 0, bottom: 0, height: 1, backgroundColor: c.border }} />
138
232
  </View>
139
- );
233
+ <View style={{ flexDirection: "row", justifyContent: "space-between" }}>
234
+ <Axis theme={theme}>{dayOf(days[0]!.day)}</Axis>
235
+ <Axis theme={theme} align="right">today</Axis>
236
+ </View>
237
+ </View>;
140
238
  }
141
239
 
142
- /** A ranked list — a bar chart of eight rows would say the same thing with more ink. */
143
- export function RankedTotals({
144
- theme,
145
- rows,
146
- total,
147
- }: {
148
- theme: Theme;
149
- rows: readonly { name: string; tokens: number }[];
150
- total: number;
151
- }) {
152
- return (
153
- <View style={{ gap: 4 }}>
154
- {rows.map((row) => (
155
- <View key={row.name} style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
156
- <View
157
- style={{
158
- flexGrow: 1,
159
- height: 4,
160
- borderRadius: 2,
161
- backgroundColor: theme.colors.surface2,
162
- overflow: "hidden",
163
- }}
164
- >
165
- <View
166
- style={{
167
- width: `${total === 0 ? 0 : (row.tokens / total) * 100}%`,
168
- height: 4,
169
- backgroundColor: theme.colors.accent,
170
- }}
171
- />
172
- </View>
173
- <Text style={{ color: theme.colors.foreground, fontSize: 12, width: 130 }} numberOfLines={1}>
174
- {row.name}
175
- </Text>
176
- <Text
177
- style={{ color: theme.colors.foregroundMuted, fontSize: 12, fontVariant: ["tabular-nums"], width: 52, textAlign: "right" }}
178
- >
179
- {formatTokens(row.tokens)}
180
- </Text>
240
+ /** A sequential ramp in ink: one hue, stepped opacity. Empty hours are the surface. */
241
+ const STEPS = [0.12, 0.26, 0.44, 0.64, 0.88] as const;
242
+ const WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
243
+
244
+ function hourLabel(hour: number, hour12?: boolean): string {
245
+ const d = new Date(2020, 0, 1, hour, 0, 0);
246
+ return hour12 === false ? `${String(hour).padStart(2, "0")}` : formatClock(d.toISOString(), hour12).replace(":00", "");
247
+ }
248
+
249
+ /** Tokens by weekday and hour of day, in local time, with a less–more key. */
250
+ export function HourHeatmap({ theme, cells, compact, hour12 }: { theme: Theme; cells: readonly { weekday: number; hour: number; tokens: number }[]; compact: boolean; hour12?: boolean }) {
251
+ const c = theme.colors;
252
+ const [hovered, setHovered] = useState<number | null>(null);
253
+ const grid = new Map<string, number>();
254
+ let max = 0;
255
+ for (const cell of cells) {
256
+ grid.set(`${cell.weekday}|${cell.hour}`, cell.tokens);
257
+ if (cell.tokens > max) max = cell.tokens;
258
+ }
259
+ const gap = 2;
260
+ const cellFill = (tokens: number) => {
261
+ const step = rampStep(tokens, max, STEPS.length);
262
+ return step === -1 ? withAlpha(c.border, 0.55) : ink(c, STEPS[step]!);
263
+ };
264
+ const readout = hovered === null ? null : { weekday: Math.floor(hovered / 24), hour: hovered % 24 };
265
+ return <View style={{ gap: 6 }}>
266
+ <Text style={{ color: readout ? c.foreground : c.foregroundMuted, fontSize: 11, lineHeight: 14, ...tabular }}>
267
+ {readout ? `${WEEKDAYS[readout.weekday]} ${hourLabel(readout.hour, hour12)} · ${formatCompactTokens(grid.get(`${readout.weekday}|${readout.hour}`) ?? 0)} tokens` : "Local time, last 30 days"}
268
+ </Text>
269
+ <View style={{ gap }}>
270
+ {WEEKDAYS.map((label, weekday) => (
271
+ <View key={label} style={{ flexDirection: "row", alignItems: "center", gap }}>
272
+ <Text style={{ color: c.foregroundMuted, fontSize: 10, width: 28 }}>{label}</Text>
273
+ {Array.from({ length: 24 }, (unused, hour) => (
274
+ <View key={hour} {...hover(setHovered, weekday * 24 + hour)} style={{ flex: 1, maxWidth: compact ? 12 : 18, aspectRatio: 1, borderRadius: 2, backgroundColor: cellFill(grid.get(`${weekday}|${hour}`) ?? 0), borderWidth: hovered === weekday * 24 + hour ? 1 : 0, borderColor: c.foreground }} />
275
+ ))}
181
276
  </View>
182
277
  ))}
183
278
  </View>
184
- );
279
+ <View style={{ flexDirection: "row", marginLeft: 28 + gap, marginTop: 2, maxWidth: 24 * ((compact ? 12 : 18) + gap) }}>
280
+ {[0, 6, 12, 18].map((hour) => (
281
+ <View key={hour} style={{ flex: 1 }}><Text numberOfLines={1} style={{ color: c.foregroundMuted, fontSize: 10, lineHeight: 13, ...tabular }}>{hourLabel(hour, hour12)}</Text></View>
282
+ ))}
283
+ </View>
284
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 4, marginTop: 10 }}>
285
+ <Axis theme={theme}>Less</Axis>
286
+ {STEPS.map((step) => <View key={step} style={{ width: 10, height: 10, borderRadius: 2, backgroundColor: ink(c, step) }} />)}
287
+ <Axis theme={theme}>{`More · busiest hour ${formatCompactTokens(max)}`}</Axis>
288
+ </View>
289
+ </View>;
290
+ }
291
+
292
+ /** A ranked list with a thin share bar: a bar chart of eight rows would say the same with more ink. */
293
+ export function RankedTotals({ theme, rows, total, compact }: { theme: Theme; rows: readonly { name: string; tokens: number }[]; total: number; compact: boolean }) {
294
+ const c = theme.colors;
295
+ const top = rows.reduce((peak, row) => Math.max(peak, row.tokens), 0);
296
+ return <View style={{ gap: 9 }}>
297
+ {rows.slice(0, compact ? 5 : 6).map((row) => (
298
+ <View key={row.name} style={{ gap: 4 }}>
299
+ <View style={{ flexDirection: "row", alignItems: "baseline", gap: 8 }}>
300
+ <Text numberOfLines={1} style={{ flex: 1, color: c.foreground, fontSize: 12, lineHeight: 16 }}>{row.name}</Text>
301
+ <Text style={{ color: c.foreground, fontSize: 12, lineHeight: 16, ...tabular }}>{formatCompactTokens(row.tokens)}</Text>
302
+ <Text style={{ width: 34, textAlign: "right", color: c.foregroundMuted, fontSize: 11, lineHeight: 16, ...tabular }}>{total === 0 ? "" : `${Math.round((row.tokens / total) * 100)}%`}</Text>
303
+ </View>
304
+ <View style={{ height: 4, borderRadius: 2, backgroundColor: ink(c, 0.08), overflow: "hidden" }}>
305
+ <View style={{ width: `${top === 0 ? 0 : (row.tokens / top) * 100}%`, height: 4, borderRadius: 2, backgroundColor: ink(c, 0.5) }} />
306
+ </View>
307
+ </View>
308
+ ))}
309
+ </View>;
185
310
  }
@@ -0,0 +1,122 @@
1
+ import { useState } from "react";
2
+ import { Platform, Pressable, Text, View } from "react-native";
3
+ import type { PluginClientContext, PluginTimelineItemProps } from "@getpaseo/plugin/client";
4
+ import { Icon, ScrollView } from "@getpaseo/plugin/client/ui";
5
+
6
+ import { PROMPT_CARD_KIND, PromptCardSchema, TOOL_ROW_KIND, ToolRowSchema, fileName, promptCard, toolRow, type PromptCard, type ToolRow } from "../shared/chat";
7
+ import { raised } from "./theme";
8
+
9
+ const ICONS: Record<ToolRow["tool"], string> = {
10
+ context_status: "FoldVertical",
11
+ budget_status: "Gauge",
12
+ checkpoint: "Save",
13
+ request_compaction: "FoldVertical",
14
+ defer_compaction: "Clock",
15
+ };
16
+
17
+ const mono = () => (Platform.OS === "ios" ? "Menlo" : Platform.OS === "android" ? "monospace" : "ui-monospace, SFMono-Regular, Menlo, monospace");
18
+
19
+ /**
20
+ * Smart Session's agent tools as one quiet line, sized like Paseo's own tool rows,
21
+ * instead of a raw `mcp__smart-session__request_compaction` call. Press to read
22
+ * what the agent saved or asked for.
23
+ */
24
+ function ToolRowView({ item, theme }: PluginTimelineItemProps<ToolRow>) {
25
+ const c = theme.colors;
26
+ const row = item.data;
27
+ const [expanded, setExpanded] = useState(false);
28
+ const [hovered, setHovered] = useState(false);
29
+ const openable = row.detail !== "";
30
+ const open = expanded && openable;
31
+ const active = openable && (hovered || expanded);
32
+ const alert = row.state === "failed" ? c.statusDanger : row.state === "refused" ? c.statusWarning : null;
33
+ return <View style={{ marginHorizontal: -13 }}>
34
+ <Pressable disabled={!openable} onPress={() => setExpanded((value) => !value)} onHoverIn={() => setHovered(true)} onHoverOut={() => setHovered(false)}
35
+ accessibilityRole={openable ? "button" : undefined} aria-expanded={openable ? expanded : undefined} aria-busy={row.state === "running"}
36
+ style={{ flexDirection: "row", alignItems: "center", borderRadius: 8, borderWidth: 1, borderColor: open ? c.border : "transparent", borderBottomLeftRadius: open ? 0 : 8, borderBottomRightRadius: open ? 0 : 8, backgroundColor: open ? c.surface1 : "transparent", paddingHorizontal: 8, paddingVertical: 4 }}>
37
+ <View style={{ width: 22, height: 22, marginRight: 4, alignItems: "center", justifyContent: "center" }}>
38
+ {active
39
+ ? <View style={{ transform: [{ rotate: expanded ? "90deg" : "0deg" }] }}><Icon name="ChevronRight" size={14} color={c.foreground} /></View>
40
+ : <Icon name={alert ? "TriangleAlert" : ICONS[row.tool]} size={12} color={alert ?? c.foregroundMuted} />}
41
+ </View>
42
+ <Text numberOfLines={1} style={{ flexShrink: 1, fontSize: 14, fontVariant: ["tabular-nums"], color: alert ?? (active || row.state === "running" ? c.foreground : c.foregroundMuted), opacity: row.state === "running" ? 0.72 : 1 }}>{row.line}</Text>
43
+ </Pressable>
44
+ {open ? <View style={{ borderWidth: 1, borderTopWidth: 0, borderColor: c.border, borderBottomLeftRadius: 8, borderBottomRightRadius: 8, overflow: "hidden" }}>
45
+ <ScrollView style={[{ maxHeight: 240 }, Platform.OS === "web" ? ({ overscrollBehavior: "contain" } as object) : null]} contentContainerStyle={{ paddingHorizontal: 12, paddingVertical: 8 }}>
46
+ <Text selectable style={{ color: alert ?? c.foreground, fontSize: 13, lineHeight: 19, fontVariant: ["tabular-nums"] }}>{row.detail}</Text>
47
+ </ScrollView>
48
+ </View> : null}
49
+ </View>;
50
+ }
51
+
52
+ const CARD: Record<PromptCard["kind"], { icon: string; title: string }> = {
53
+ compact: { icon: "FoldVertical", title: "Compaction the agent asked for" },
54
+ continue: { icon: "CornerDownRight", title: "Continue after compaction" },
55
+ "guard-continue": { icon: "ShieldCheck", title: "Continue after the reset" },
56
+ "guard-resume": { icon: "Play", title: "Resume now" },
57
+ };
58
+
59
+ function cardBody(card: PromptCard): string {
60
+ switch (card.kind) {
61
+ case "compact": return card.reason ? card.reason : "No reason given.";
62
+ case "continue": return card.statePath ? `Re-read ${fileName(card.statePath)} and carry on from its current step.` : "Pick the task up from the summary and carry on.";
63
+ case "guard-continue": return "The session window reset. Pick up the unfinished task where it stopped.";
64
+ case "guard-resume": return "You resumed this session before the reset. The provider may still refuse work until then.";
65
+ }
66
+ }
67
+
68
+ /** A message Smart Session sent, as a card instead of a wall of instructions. */
69
+ function PromptCardView({ item, theme, layout }: PluginTimelineItemProps<PromptCard>) {
70
+ const c = theme.colors;
71
+ const card = item.data;
72
+ const [expanded, setExpanded] = useState(false);
73
+ const look = CARD[card.kind];
74
+ const sender = card.kind.startsWith("guard") ? "Session Guard" : "Smart Compact";
75
+ return <View style={{ alignSelf: layout.compact ? "stretch" : "flex-end", width: "100%", maxWidth: 560, marginVertical: 4 }}>
76
+ <View style={[{ borderRadius: 12, backgroundColor: c.surface1, overflow: "hidden" }, raised(c)]}>
77
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8, paddingHorizontal: 14, paddingTop: 11, paddingBottom: 4 }}>
78
+ <Icon name={look.icon} size={14} color={c.foregroundMuted} />
79
+ <Text numberOfLines={1} style={{ flex: 1, color: c.foreground, fontSize: 13, lineHeight: 18, fontWeight: "600" }}>{look.title}</Text>
80
+ <Pressable accessibilityRole="button" aria-expanded={expanded} accessibilityLabel={expanded ? "Hide the full message" : "Show the full message"} onPress={() => setExpanded((v) => !v)} hitSlop={6}
81
+ style={(state) => ({ paddingHorizontal: 6, paddingVertical: 2, borderRadius: 4, backgroundColor: state.pressed ? c.surface2 : "transparent" })}>
82
+ <Text style={{ color: c.foregroundMuted, fontSize: 12 }}>{expanded ? "Hide" : "Full message"}</Text>
83
+ </Pressable>
84
+ </View>
85
+ <View style={{ paddingHorizontal: 14, paddingBottom: 11, gap: 4 }}>
86
+ <Text selectable style={{ color: c.foreground, fontSize: 13, lineHeight: 19 }}>{cardBody(card)}</Text>
87
+ <Text style={{ color: c.foregroundMuted, fontSize: 12, lineHeight: 17 }}>
88
+ {card.kind === "compact" ? `Sent by ${sender} once the agent was idle${card.statePath ? ` · keeps the goal, current step, decisions and dead ends · state in ${fileName(card.statePath)}` : ""}` : `Sent by ${sender}`}
89
+ </Text>
90
+ </View>
91
+ {expanded ? <View style={{ borderTopWidth: 1, borderTopColor: c.border }}>
92
+ <ScrollView style={[{ maxHeight: 300 }, Platform.OS === "web" ? ({ overscrollBehavior: "contain" } as object) : null]} contentContainerStyle={{ padding: 14 }}>
93
+ <Text selectable style={{ color: c.foregroundMuted, fontFamily: mono(), fontSize: 12, lineHeight: 18 }}>{card.text}</Text>
94
+ </ScrollView>
95
+ </View> : null}
96
+ </View>
97
+ </View>;
98
+ }
99
+
100
+ export function contributeChat(client: PluginClientContext) {
101
+ const removers = [
102
+ client.addTimelineTransformer({
103
+ id: "smart-session-tool-calls",
104
+ query: { itemType: "tool_call" },
105
+ transform({ item }) {
106
+ const row = toolRow(item.name, item.status, item.detail, item.error);
107
+ return row === null ? undefined : { items: [{ type: "plugin" as const, kind: TOOL_ROW_KIND, version: 1, data: row }] };
108
+ },
109
+ }),
110
+ client.addTimelineTransformer({
111
+ id: "smart-session-prompts",
112
+ query: { itemType: "user_message" },
113
+ transform({ item }) {
114
+ const card = promptCard(item.text);
115
+ return card === null ? undefined : { items: [{ type: "plugin" as const, kind: PROMPT_CARD_KIND, version: 1, data: card }] };
116
+ },
117
+ }),
118
+ client.addTimelineRenderer({ kind: TOOL_ROW_KIND, version: 1, schema: ToolRowSchema, Component: ToolRowView }),
119
+ client.addTimelineRenderer({ kind: PROMPT_CARD_KIND, version: 1, schema: PromptCardSchema, Component: PromptCardView }),
120
+ ];
121
+ return () => { for (const remove of removers) if (typeof remove === "function") void remove(); };
122
+ }