paseo-smart-session 1.2.1

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.
@@ -0,0 +1,574 @@
1
+ import { type PluginSurfaceProps, useRpc } from "@getpaseo/plugin/client";
2
+ import { Icon, useToast } from "@getpaseo/plugin/client/react-native";
3
+ import { useQuery, useQueryClient } from "@tanstack/react-query";
4
+ import React, { useEffect, useState } from "react";
5
+ import { Pressable, ScrollView, Text, TextInput, View } from "react-native";
6
+
7
+ import { DailyBars, HourHeatmap, RankedTotals } from "./charts";
8
+ import { refreshPills } from "./pill";
9
+ import { enrolmentState } from "../shared/governor";
10
+ import { formatRelative, formatTokens, isInteresting, windowLabel } from "../shared/format";
11
+ import {
12
+ budgetStatus,
13
+ contextStatus,
14
+ getSettings,
15
+ installStatus,
16
+ setSettings,
17
+ spendSummary,
18
+ } from "../shared/smart-session";
19
+ import { withCompactThreshold } from "../shared/thresholds";
20
+
21
+ type Theme = PluginSurfaceProps["theme"];
22
+
23
+ /** Green under two thirds, amber past it, red once there is little room left. */
24
+ function toneFor(theme: Theme, pct: number): string {
25
+ if (pct >= 90) return theme.colors.statusDanger;
26
+ if (pct >= 66) return theme.colors.statusWarning;
27
+ return theme.colors.accent;
28
+ }
29
+
30
+ function Bar({ theme, pct }: { theme: Theme; pct: number }) {
31
+ const clamped = Math.max(0, Math.min(100, pct));
32
+ return (
33
+ <View style={{ height: 6, borderRadius: 3, backgroundColor: theme.colors.surface2, overflow: "hidden" }}>
34
+ <View style={{ width: `${clamped}%`, height: 6, backgroundColor: toneFor(theme, clamped) }} />
35
+ </View>
36
+ );
37
+ }
38
+
39
+ function Row({
40
+ theme,
41
+ title,
42
+ detail,
43
+ pct,
44
+ trailing,
45
+ }: {
46
+ theme: Theme;
47
+ title: string;
48
+ detail: string;
49
+ pct: number;
50
+ trailing: string;
51
+ }) {
52
+ return (
53
+ <View style={{ gap: 6, paddingVertical: 8 }}>
54
+ <View style={{ flexDirection: "row", alignItems: "baseline", gap: 8 }}>
55
+ <Text style={{ color: theme.colors.foreground, fontWeight: "600", flexGrow: 1 }}>{title}</Text>
56
+ <Text style={{ color: toneFor(theme, pct), fontVariant: ["tabular-nums"] }}>{trailing}</Text>
57
+ </View>
58
+ <Bar theme={theme} pct={pct} />
59
+ <Text style={{ color: theme.colors.foregroundMuted, fontSize: 12 }}>{detail}</Text>
60
+ </View>
61
+ );
62
+ }
63
+
64
+ function Section({ theme, title, children }: { theme: Theme; title: string; children: React.ReactNode }) {
65
+ return (
66
+ <View style={{ gap: 2 }}>
67
+ <Text
68
+ style={{
69
+ color: theme.colors.foregroundMuted,
70
+ fontSize: 11,
71
+ letterSpacing: 1,
72
+ textTransform: "uppercase",
73
+ marginBottom: 4,
74
+ }}
75
+ >
76
+ {title}
77
+ </Text>
78
+ {children}
79
+ </View>
80
+ );
81
+ }
82
+
83
+ /**
84
+ * A switch, and the one thing it controls.
85
+ *
86
+ * `disabled` is for the options that only mean something while Smart Compact is on.
87
+ * They stay visible and keep showing their own value — hiding them would make the
88
+ * master switch look like it had erased them — but they dim and stop responding,
89
+ * which is what says "this one depends on the one above".
90
+ */
91
+ function Toggle({
92
+ theme,
93
+ on,
94
+ label,
95
+ detail,
96
+ onPress,
97
+ disabled = false,
98
+ }: {
99
+ theme: Theme;
100
+ on: boolean;
101
+ label: string;
102
+ detail: string;
103
+ onPress: () => void;
104
+ disabled?: boolean;
105
+ }) {
106
+ return (
107
+ <Pressable
108
+ onPress={disabled ? undefined : onPress}
109
+ disabled={disabled}
110
+ style={{
111
+ flexDirection: "row",
112
+ alignItems: "center",
113
+ gap: 12,
114
+ paddingVertical: 10,
115
+ paddingHorizontal: 12,
116
+ borderRadius: 8,
117
+ borderWidth: 1,
118
+ borderColor: theme.colors.border,
119
+ backgroundColor: theme.colors.surface1,
120
+ opacity: disabled ? 0.45 : 1,
121
+ }}
122
+ >
123
+ <View
124
+ style={{
125
+ width: 36,
126
+ height: 20,
127
+ borderRadius: 10,
128
+ padding: 2,
129
+ backgroundColor: on ? theme.colors.accent : theme.colors.surface2,
130
+ justifyContent: "center",
131
+ alignItems: on ? "flex-end" : "flex-start",
132
+ }}
133
+ >
134
+ <View style={{ width: 16, height: 16, borderRadius: 8, backgroundColor: theme.colors.accentForeground }} />
135
+ </View>
136
+ <View style={{ flexShrink: 1, gap: 2 }}>
137
+ <Text style={{ color: theme.colors.foreground, fontWeight: "600" }}>{label}</Text>
138
+ <Text style={{ color: theme.colors.foregroundMuted, fontSize: 12 }}>{detail}</Text>
139
+ </View>
140
+ </Pressable>
141
+ );
142
+ }
143
+
144
+ function CompactThreshold({
145
+ theme,
146
+ label,
147
+ detail,
148
+ value,
149
+ disabled,
150
+ onSave,
151
+ }: {
152
+ theme: Theme;
153
+ label: string;
154
+ detail: string;
155
+ value: number;
156
+ disabled: boolean;
157
+ onSave: (value: number) => Promise<void>;
158
+ }) {
159
+ const [draft, setDraft] = useState(String(value));
160
+ const [saving, setSaving] = useState(false);
161
+ useEffect(() => setDraft(String(value)), [value]);
162
+
163
+ const parsed = Number(draft.trim());
164
+ const valid = Number.isInteger(parsed) && parsed >= 1 && parsed <= 99;
165
+ const changed = valid && parsed !== value;
166
+
167
+ function save() {
168
+ if (disabled || !changed || saving) return;
169
+ setSaving(true);
170
+ void onSave(parsed).finally(() => setSaving(false));
171
+ }
172
+
173
+ return (
174
+ <View
175
+ style={{
176
+ gap: 8,
177
+ paddingVertical: 10,
178
+ paddingHorizontal: 12,
179
+ borderRadius: 8,
180
+ borderWidth: 1,
181
+ borderColor: theme.colors.border,
182
+ backgroundColor: theme.colors.surface1,
183
+ opacity: disabled ? 0.45 : 1,
184
+ }}
185
+ >
186
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 12, flexWrap: "wrap" }}>
187
+ <View style={{ flexGrow: 1, flexShrink: 1, gap: 2 }}>
188
+ <Text style={{ color: theme.colors.foreground, fontWeight: "600" }}>{label}</Text>
189
+ <Text style={{ color: theme.colors.foregroundMuted, fontSize: 12 }}>{detail}</Text>
190
+ </View>
191
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 6, flexShrink: 0 }}>
192
+ <TextInput
193
+ value={draft}
194
+ onChangeText={setDraft}
195
+ onSubmitEditing={save}
196
+ editable={!disabled && !saving}
197
+ keyboardType="number-pad"
198
+ maxLength={2}
199
+ selectTextOnFocus
200
+ accessibilityLabel={`${label} percentage`}
201
+ style={{
202
+ width: 48,
203
+ paddingVertical: 6,
204
+ paddingHorizontal: 8,
205
+ borderRadius: 6,
206
+ borderWidth: 1,
207
+ borderColor: valid ? theme.colors.border : theme.colors.statusDanger,
208
+ backgroundColor: theme.colors.surface0,
209
+ color: theme.colors.foreground,
210
+ textAlign: "right",
211
+ fontVariant: ["tabular-nums"],
212
+ }}
213
+ />
214
+ <Text style={{ color: theme.colors.foregroundMuted }}>%</Text>
215
+ <Pressable
216
+ onPress={save}
217
+ disabled={disabled || !changed || saving}
218
+ style={{
219
+ paddingVertical: 7,
220
+ paddingHorizontal: 10,
221
+ borderRadius: 6,
222
+ backgroundColor: changed ? theme.colors.accent : theme.colors.surface2,
223
+ opacity: disabled || !changed || saving ? 0.5 : 1,
224
+ }}
225
+ >
226
+ <Text style={{ color: changed ? theme.colors.accentForeground : theme.colors.foregroundMuted }}>
227
+ {saving ? "Saving…" : "Save"}
228
+ </Text>
229
+ </Pressable>
230
+ </View>
231
+ </View>
232
+ {!valid ? (
233
+ <Text style={{ color: theme.colors.statusDanger, fontSize: 11 }}>Enter a whole number from 1 to 99.</Text>
234
+ ) : null}
235
+ </View>
236
+ );
237
+ }
238
+
239
+ export function SmartSessionSurface({ theme, layout }: PluginSurfaceProps) {
240
+ const budget = useRpc(budgetStatus);
241
+ const context = useRpc(contextStatus);
242
+ const spend = useRpc(spendSummary);
243
+ const readSettings = useRpc(getSettings);
244
+ const writeSettings = useRpc(setSettings);
245
+ const readEnrolment = useRpc(enrolmentState);
246
+ const readInstall = useRpc(installStatus);
247
+ const toast = useToast();
248
+ const queryClient = useQueryClient();
249
+
250
+ // The daemon serves usage from a five-minute cache, so polling it harder than
251
+ // the recorder does would only redraw the same number.
252
+ const budgetQuery = useQuery({ queryKey: ["smart-session", "budget"], queryFn: () => budget({}), refetchInterval: 30_000 });
253
+ const contextQuery = useQuery({
254
+ queryKey: ["smart-session", "context"],
255
+ queryFn: () => context({}),
256
+ refetchInterval: 15_000,
257
+ });
258
+
259
+ // Reconstructed from transcripts, which only change when an agent writes a turn.
260
+ const spendQuery = useQuery({
261
+ queryKey: ["smart-session", "spend"],
262
+ queryFn: () => spend({ days: 30 }),
263
+ refetchInterval: 5 * 60_000,
264
+ });
265
+
266
+ const settingsQuery = useQuery({
267
+ queryKey: ["smart-session", "settings"],
268
+ queryFn: () => readSettings({}),
269
+ refetchInterval: 60_000,
270
+ });
271
+
272
+ // Cheap on every call after the first: with nothing to reconcile it reads two
273
+ // files and writes none.
274
+ const installQuery = useQuery({
275
+ queryKey: ["smart-session", "install"],
276
+ queryFn: () => readInstall({}),
277
+ refetchInterval: 5 * 60_000,
278
+ });
279
+
280
+ const settings = settingsQuery.data?.settings;
281
+ const enrolled = settingsQuery.data?.enrolledAgents ?? 0;
282
+ const install = installQuery.data?.report;
283
+
284
+ function toggleInstallHooks() {
285
+ if (settings === undefined) return;
286
+ const next = !settings.installHooks;
287
+ void writeSettings({ installHooks: next })
288
+ .then(() => {
289
+ toast.show(next ? "Hooks registered with Claude Code" : "Hooks removed from Claude Code");
290
+ return Promise.all([
291
+ queryClient.invalidateQueries({ queryKey: ["smart-session", "settings"] }),
292
+ queryClient.invalidateQueries({ queryKey: ["smart-session", "install"] }),
293
+ ]);
294
+ })
295
+ .catch((error: unknown) => toast.error(error instanceof Error ? error.message : String(error)));
296
+ }
297
+
298
+ function toggleEnabled() {
299
+ if (settings === undefined) return;
300
+ const next = !settings.enabled;
301
+ void writeSettings({ enabled: next })
302
+ // The pills live in this same client bundle, and the master switch decides
303
+ // whether they exist at all, so they are told directly rather than waiting
304
+ // out their own refresh interval.
305
+ .then(() => refreshPills(() => readEnrolment({})))
306
+ .then(() => {
307
+ toast.show(next ? "Smart Compact on" : "Smart Compact off");
308
+ return queryClient.invalidateQueries({ queryKey: ["smart-session", "settings"] });
309
+ })
310
+ .catch((error: unknown) => toast.error(error instanceof Error ? error.message : String(error)));
311
+ }
312
+
313
+ function toggleAutoEnrol() {
314
+ if (settings === undefined) return;
315
+ const next = !settings.autoEnrol;
316
+ void writeSettings({ autoEnrol: next })
317
+ .then(() => refreshPills(() => readEnrolment({})))
318
+ .then(() => {
319
+ toast.show(next ? "Sessions enrol themselves" : "Sessions are enrolled one at a time");
320
+ return queryClient.invalidateQueries({ queryKey: ["smart-session", "settings"] });
321
+ })
322
+ .catch((error: unknown) => toast.error(error instanceof Error ? error.message : String(error)));
323
+ }
324
+
325
+ function togglePill() {
326
+ if (settings === undefined) return;
327
+ const next = !settings.showPill;
328
+ void writeSettings({ showPill: next })
329
+ // The pills live in this same client bundle, so they can be told directly
330
+ // rather than waiting out their own refresh interval.
331
+ .then(() => refreshPills(() => readEnrolment({})))
332
+ .then(() => {
333
+ toast.show(next ? "Pill shown on every agent" : "Pill hidden");
334
+ return queryClient.invalidateQueries({ queryKey: ["smart-session", "settings"] });
335
+ })
336
+ .catch((error: unknown) => toast.error(error instanceof Error ? error.message : String(error)));
337
+ }
338
+
339
+ async function saveCompactThreshold(profile: "large" | "small", compact: number): Promise<void> {
340
+ if (settings === undefined) return;
341
+ try {
342
+ const result = await writeSettings({
343
+ thresholds: {
344
+ ...settings.thresholds,
345
+ [profile]: withCompactThreshold(settings.thresholds[profile], compact),
346
+ },
347
+ });
348
+ queryClient.setQueryData(["smart-session", "settings"], {
349
+ settings: result.settings,
350
+ enrolledAgents: enrolled,
351
+ });
352
+ await queryClient.invalidateQueries({ queryKey: ["smart-session", "context"] });
353
+ toast.show(`Smart Compact will ask at ${compact}% for ${profile} windows`);
354
+ } catch (error: unknown) {
355
+ toast.error(error instanceof Error ? error.message : String(error));
356
+ }
357
+ }
358
+
359
+ const padding = layout.compact ? 12 : 20;
360
+ const windows = (budgetQuery.data?.windows ?? []).filter(isInteresting);
361
+ const agents = contextQuery.data?.agents ?? [];
362
+ const recorder = budgetQuery.data?.recorder;
363
+ const summary = spendQuery.data?.summary;
364
+ const error = budgetQuery.data?.error ?? contextQuery.data?.error ?? null;
365
+
366
+ return (
367
+ <ScrollView style={{ flex: 1, backgroundColor: theme.colors.surface0 }} contentContainerStyle={{ padding, gap: 24 }}>
368
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
369
+ <Icon name="Gauge" size={18} color={theme.colors.foreground} />
370
+ <Text style={{ color: theme.colors.foreground, fontSize: 18, fontWeight: "700" }}>Smart Session</Text>
371
+ </View>
372
+
373
+ {error !== null ? (
374
+ <Text style={{ color: theme.colors.statusDanger }}>{error}</Text>
375
+ ) : null}
376
+
377
+ <Section theme={theme} title="Plan limits">
378
+ {windows.length === 0 ? (
379
+ <Text style={{ color: theme.colors.foregroundMuted }}>
380
+ No reading yet. The recorder samples every minute.
381
+ </Text>
382
+ ) : (
383
+ windows.map((window) => (
384
+ <Row
385
+ key={window.id}
386
+ theme={theme}
387
+ title={windowLabel(window.id)}
388
+ pct={window.pct}
389
+ trailing={`${Math.round(window.pct)}%`}
390
+ detail={[
391
+ window.resetsAt === null ? null : `resets ${formatRelative(window.resetsAt)}`,
392
+ window.burnPctPerHour === null || window.burnPctPerHour <= 0
393
+ ? null
394
+ : `${window.burnPctPerHour.toFixed(1)}%/h`,
395
+ window.projectedFullAt === null ? null : `full ${formatRelative(window.projectedFullAt)}`,
396
+ ]
397
+ .filter((part) => part !== null)
398
+ .join(" · ")}
399
+ />
400
+ ))
401
+ )}
402
+ </Section>
403
+
404
+ <Section theme={theme} title="Agent context">
405
+ {agents.length === 0 ? (
406
+ <Text style={{ color: theme.colors.foregroundMuted }}>No agent has reported a turn yet.</Text>
407
+ ) : (
408
+ agents.map((agent) => (
409
+ <Row
410
+ key={agent.agentId}
411
+ theme={theme}
412
+ title={agent.title ?? agent.agentId.slice(0, 8)}
413
+ pct={agent.usedPct}
414
+ trailing={`${agent.usedPct.toFixed(1)}%`}
415
+ detail={`${formatTokens(agent.usedTokens)} / ${formatTokens(agent.maxTokens)} · compact at ${
416
+ agent.compactAtPct
417
+ }% · ${agent.status}${agent.model === null ? "" : ` · ${agent.model}`}`}
418
+ />
419
+ ))
420
+ )}
421
+ </Section>
422
+
423
+ {summary === undefined || summary.bucketCount === 0 ? null : (
424
+ <Section theme={theme} title="Where the tokens went">
425
+ <View style={{ flexDirection: "row", gap: 24, marginBottom: 12, flexWrap: "wrap" }}>
426
+ <View>
427
+ <Text style={{ color: theme.colors.foreground, fontSize: 22, fontWeight: "700" }}>
428
+ {formatTokens(summary.totalTokens)}
429
+ </Text>
430
+ <Text style={{ color: theme.colors.foregroundMuted, fontSize: 11 }}>
431
+ billable tokens, last 30 days
432
+ </Text>
433
+ </View>
434
+ <View>
435
+ <Text style={{ color: theme.colors.foreground, fontSize: 22, fontWeight: "700" }}>
436
+ {Math.round(summary.cacheHitPct)}%
437
+ </Text>
438
+ <Text style={{ color: theme.colors.foregroundMuted, fontSize: 11 }}>served from cache</Text>
439
+ </View>
440
+ {summary.subagentTokens === 0 ? null : (
441
+ <View>
442
+ <Text style={{ color: theme.colors.foreground, fontSize: 22, fontWeight: "700" }}>
443
+ {formatTokens(summary.subagentTokens)}
444
+ </Text>
445
+ <Text style={{ color: theme.colors.foregroundMuted, fontSize: 11 }}>from subagents</Text>
446
+ </View>
447
+ )}
448
+ </View>
449
+
450
+ <Text style={{ color: theme.colors.foregroundMuted, fontSize: 12, marginBottom: 6 }}>Tokens per day</Text>
451
+ <DailyBars theme={theme} days={summary.byDay.slice(-30)} compact={layout.compact} />
452
+
453
+ <Text style={{ color: theme.colors.foregroundMuted, fontSize: 12, marginTop: 16, marginBottom: 6 }}>
454
+ Tokens by hour of the week
455
+ </Text>
456
+ <HourHeatmap theme={theme} cells={summary.heatmap} compact={layout.compact} />
457
+
458
+ <Text style={{ color: theme.colors.foregroundMuted, fontSize: 12, marginTop: 16, marginBottom: 6 }}>
459
+ By workspace
460
+ </Text>
461
+ <RankedTotals theme={theme} rows={summary.byProject} total={summary.totalTokens} />
462
+
463
+ <Text style={{ color: theme.colors.foregroundMuted, fontSize: 12, marginTop: 12, marginBottom: 6 }}>
464
+ By model
465
+ </Text>
466
+ <RankedTotals theme={theme} rows={summary.byModel} total={summary.totalTokens} />
467
+ </Section>
468
+ )}
469
+
470
+ {settings === undefined ? null : (
471
+ <Section theme={theme} title="Smart Compact">
472
+ <Toggle
473
+ theme={theme}
474
+ on={settings.enabled}
475
+ label="Smart Compact"
476
+ detail={
477
+ settings.enabled
478
+ ? `On. Past ${settings.thresholds.large.compact}% of a large window or ${settings.thresholds.small.compact}% of a small one, a session is asked at the end of a turn whether to compact itself — and compacted only if it says yes. ${enrolled} session${
479
+ enrolled === 1 ? "" : "s"
480
+ } enrolled.`
481
+ : "Off. Nothing is asked and nothing is compacted. A request an agent already made waits rather than failing."
482
+ }
483
+ onPress={toggleEnabled}
484
+ />
485
+ <View style={{ marginTop: 8 }}>
486
+ <CompactThreshold
487
+ theme={theme}
488
+ label="Large-window compact threshold"
489
+ detail={`For context windows of ${formatTokens(settings.thresholds.largeWindowFrom)} or more. Earlier notice bands move down when needed.`}
490
+ value={settings.thresholds.large.compact}
491
+ disabled={!settings.enabled}
492
+ onSave={(value) => saveCompactThreshold("large", value)}
493
+ />
494
+ </View>
495
+ <View style={{ marginTop: 8 }}>
496
+ <CompactThreshold
497
+ theme={theme}
498
+ label="Small-window compact threshold"
499
+ detail={`For context windows below ${formatTokens(settings.thresholds.largeWindowFrom)}. Earlier notice bands move down when needed.`}
500
+ value={settings.thresholds.small.compact}
501
+ disabled={!settings.enabled}
502
+ onSave={(value) => saveCompactThreshold("small", value)}
503
+ />
504
+ </View>
505
+ <View style={{ marginTop: 8 }}>
506
+ <Toggle
507
+ theme={theme}
508
+ on={settings.autoEnrol}
509
+ disabled={!settings.enabled}
510
+ label="Enrol sessions automatically"
511
+ detail="On, a session that has written task state with the checkpoint tool is enrolled by that alone. Off, each session is enrolled by hand from its pill."
512
+ onPress={toggleAutoEnrol}
513
+ />
514
+ </View>
515
+ <View style={{ marginTop: 8 }}>
516
+ <Toggle
517
+ theme={theme}
518
+ on={settings.showPill}
519
+ disabled={!settings.enabled}
520
+ label="Show the pill on every agent"
521
+ detail="Puts the smart-compact state on each composer, where pressing it enrols that session or takes it out."
522
+ onPress={togglePill}
523
+ />
524
+ </View>
525
+ <View style={{ marginTop: 8 }}>
526
+ <Toggle
527
+ theme={theme}
528
+ on={settings.installHooks}
529
+ disabled={!settings.enabled}
530
+ label="Register the hooks with Claude Code"
531
+ detail={
532
+ install === undefined
533
+ ? "Keeps this plugin's hooks in ~/.claude/settings.json up to date. Nothing asks a session to compact itself without them."
534
+ : install.error !== null
535
+ ? install.error
536
+ : install.pluginDir === null
537
+ ? "Not registered. Nothing will ask a session to compact itself."
538
+ : `Registered: ${install.hooks.join(", ")}. Only entries pointing at this plugin are touched; the MCP server is ${
539
+ install.mcp === "unavailable" ? "not registered — the claude CLI was not reachable" : "registered"
540
+ }.`
541
+ }
542
+ onPress={toggleInstallHooks}
543
+ />
544
+ </View>
545
+ <Text style={{ color: theme.colors.foregroundMuted, fontSize: 11, marginTop: 6 }}>
546
+ A session is never compacted unless it asks. Paseo puts the question at a turn boundary
547
+ and sends the /compact it asked for. The agent also chooses whether to start another turn
548
+ afterwards and can supply that follow-up message itself.
549
+ </Text>
550
+ <Text style={{ color: theme.colors.foregroundMuted, fontSize: 11, marginTop: 4 }}>
551
+ {`Asked at ${settings.thresholds.large.compact}% of a ${formatTokens(settings.thresholds.largeWindowFrom)}+ window (${formatTokens(
552
+ Math.round((settings.thresholds.large.compact / 100) * 1_000_000),
553
+ )} tokens on a 1M session), or ${settings.thresholds.small.compact}% of a smaller one. What tires a context is its absolute size, not its share of the window.`}
554
+ </Text>
555
+ </Section>
556
+ )}
557
+
558
+ {recorder === undefined ? null : (
559
+ <Section theme={theme} title="Recorder">
560
+ <Text style={{ color: theme.colors.foregroundMuted, fontSize: 12 }}>
561
+ {recorder.newestAt === null
562
+ ? "Nothing recorded yet."
563
+ : `Newest reading ${formatRelative(recorder.newestAt)} from ${recorder.source ?? "?"} · ${
564
+ recorder.samplesHeld
565
+ } samples held${
566
+ recorder.firstSampleAt === null ? "" : ` since ${formatRelative(recorder.firstSampleAt)}`
567
+ }`}
568
+ </Text>
569
+ <Text style={{ color: theme.colors.foregroundMuted, fontSize: 11 }}>{recorder.dataDir}</Text>
570
+ </Section>
571
+ )}
572
+ </ScrollView>
573
+ );
574
+ }