@xpufx/paseo-top 0.4.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 (111) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +114 -0
  3. package/client/pill-labels.ts +480 -0
  4. package/client/pill.tsx +2461 -0
  5. package/client/resources-query.ts +68 -0
  6. package/client/settings-ui.tsx +50 -0
  7. package/client/surface.tsx +364 -0
  8. package/client/telemetry-copy.ts +105 -0
  9. package/client/telemetry.tsx +486 -0
  10. package/client/turn-counter.ts +151 -0
  11. package/client/turn-panel.tsx +182 -0
  12. package/client/vendor/paseo-plugin-helper/command-center.ts +43 -0
  13. package/client/vendor/paseo-plugin-helper/components/AboutSection.tsx +493 -0
  14. package/client/vendor/paseo-plugin-helper/components/AttentionBeacon.tsx +250 -0
  15. package/client/vendor/paseo-plugin-helper/components/Badge.tsx +156 -0
  16. package/client/vendor/paseo-plugin-helper/components/Button.tsx +178 -0
  17. package/client/vendor/paseo-plugin-helper/components/Card.tsx +225 -0
  18. package/client/vendor/paseo-plugin-helper/components/CodeBlock.tsx +196 -0
  19. package/client/vendor/paseo-plugin-helper/components/Collapsible.tsx +277 -0
  20. package/client/vendor/paseo-plugin-helper/components/CommandBox.tsx +172 -0
  21. package/client/vendor/paseo-plugin-helper/components/CopyButton.tsx +180 -0
  22. package/client/vendor/paseo-plugin-helper/components/DataTable.tsx +200 -0
  23. package/client/vendor/paseo-plugin-helper/components/EmptyState.tsx +97 -0
  24. package/client/vendor/paseo-plugin-helper/components/HighlightedText.tsx +70 -0
  25. package/client/vendor/paseo-plugin-helper/components/InlineButton.tsx +73 -0
  26. package/client/vendor/paseo-plugin-helper/components/KeyValue.tsx +446 -0
  27. package/client/vendor/paseo-plugin-helper/components/MetricGauge.tsx +247 -0
  28. package/client/vendor/paseo-plugin-helper/components/ProgressBar.tsx +117 -0
  29. package/client/vendor/paseo-plugin-helper/components/Responsive.tsx +53 -0
  30. package/client/vendor/paseo-plugin-helper/components/SearchInput.tsx +118 -0
  31. package/client/vendor/paseo-plugin-helper/components/SectionHeader.tsx +80 -0
  32. package/client/vendor/paseo-plugin-helper/components/Select.tsx +215 -0
  33. package/client/vendor/paseo-plugin-helper/components/StatusDot.tsx +80 -0
  34. package/client/vendor/paseo-plugin-helper/components/Tabs.tsx +319 -0
  35. package/client/vendor/paseo-plugin-helper/components/TextInput.tsx +150 -0
  36. package/client/vendor/paseo-plugin-helper/components/Toggle.tsx +163 -0
  37. package/client/vendor/paseo-plugin-helper/components/TruncatedText.tsx +157 -0
  38. package/client/vendor/paseo-plugin-helper/components/index.ts +25 -0
  39. package/client/vendor/paseo-plugin-helper/custom-pills.tsx +224 -0
  40. package/client/vendor/paseo-plugin-helper/forge-icon.tsx +79 -0
  41. package/client/vendor/paseo-plugin-helper/host.ts +277 -0
  42. package/client/vendor/paseo-plugin-helper/icon.tsx +39 -0
  43. package/client/vendor/paseo-plugin-helper/index.ts +28 -0
  44. package/client/vendor/paseo-plugin-helper/layout/ActionBar.tsx +49 -0
  45. package/client/vendor/paseo-plugin-helper/layout/FormRow.tsx +103 -0
  46. package/client/vendor/paseo-plugin-helper/layout/Grid.tsx +65 -0
  47. package/client/vendor/paseo-plugin-helper/layout/ModalBody.tsx +378 -0
  48. package/client/vendor/paseo-plugin-helper/layout/ModalContent.tsx +49 -0
  49. package/client/vendor/paseo-plugin-helper/layout/Row.tsx +39 -0
  50. package/client/vendor/paseo-plugin-helper/layout/Stack.tsx +39 -0
  51. package/client/vendor/paseo-plugin-helper/layout/index.ts +7 -0
  52. package/client/vendor/paseo-plugin-helper/panel.tsx +81 -0
  53. package/client/vendor/paseo-plugin-helper/pill.tsx +884 -0
  54. package/client/vendor/paseo-plugin-helper/query-refresh.ts +79 -0
  55. package/client/vendor/paseo-plugin-helper/query.ts +66 -0
  56. package/client/vendor/paseo-plugin-helper/settings-screen.tsx +372 -0
  57. package/client/vendor/paseo-plugin-helper/settings.ts +181 -0
  58. package/client/vendor/paseo-plugin-helper/shared-settings.ts +46 -0
  59. package/client/vendor/paseo-plugin-helper/snapshot.ts +68 -0
  60. package/client/vendor/paseo-plugin-helper/surface.tsx +80 -0
  61. package/client/vendor/paseo-plugin-helper/theme/color-utils.ts +118 -0
  62. package/client/vendor/paseo-plugin-helper/theme/flair.ts +76 -0
  63. package/client/vendor/paseo-plugin-helper/theme/host-variables.ts +121 -0
  64. package/client/vendor/paseo-plugin-helper/theme/index.ts +7 -0
  65. package/client/vendor/paseo-plugin-helper/theme/provider.tsx +214 -0
  66. package/client/vendor/paseo-plugin-helper/theme/responsive.ts +213 -0
  67. package/client/vendor/paseo-plugin-helper/theme/tokens.ts +161 -0
  68. package/client/vendor/paseo-plugin-helper/theme/useResponsive.ts +57 -0
  69. package/client/vendor/paseo-plugin-helper/utils/clipboard.ts +149 -0
  70. package/client/vendor/paseo-plugin-helper/utils/haptics.ts +34 -0
  71. package/examples/pills/battery.jsonc +23 -0
  72. package/examples/pills/disk-usage.jsonc +18 -0
  73. package/examples/pills/docker-containers.jsonc +22 -0
  74. package/examples/pills/git-dirty.jsonc +22 -0
  75. package/examples/pills/gpu-nvidia.jsonc +22 -0
  76. package/package.json +37 -0
  77. package/paseo-plugin.json +4 -0
  78. package/server/resources.ts +784 -0
  79. package/server/vendor/paseo-plugin-helper/agent.ts +85 -0
  80. package/server/vendor/paseo-plugin-helper/custom-pills.ts +344 -0
  81. package/server/vendor/paseo-plugin-helper/index.ts +18 -0
  82. package/server/vendor/paseo-plugin-helper/jsonc.ts +78 -0
  83. package/server/vendor/paseo-plugin-helper/logger.ts +210 -0
  84. package/server/vendor/paseo-plugin-helper/mcp-config.ts +367 -0
  85. package/server/vendor/paseo-plugin-helper/mcp-injection.ts +85 -0
  86. package/server/vendor/paseo-plugin-helper/network.ts +91 -0
  87. package/server/vendor/paseo-plugin-helper/plugins.ts +160 -0
  88. package/server/vendor/paseo-plugin-helper/process.ts +186 -0
  89. package/server/vendor/paseo-plugin-helper/redact.ts +86 -0
  90. package/server/vendor/paseo-plugin-helper/rpc-guard.ts +77 -0
  91. package/server/vendor/paseo-plugin-helper/settings.ts +97 -0
  92. package/server/vendor/paseo-plugin-helper/shared-settings.ts +243 -0
  93. package/server/vendor/paseo-plugin-helper/storage.ts +244 -0
  94. package/server/vendor/paseo-plugin-helper/system.ts +128 -0
  95. package/server/vendor/paseo-plugin-helper/task.ts +116 -0
  96. package/server/vendor/paseo-plugin-helper/version.ts +153 -0
  97. package/server/vendor/paseo-plugin-helper/workspace-beacon.ts +418 -0
  98. package/shared/resources.ts +686 -0
  99. package/shared/vendor/paseo-plugin-helper/README.md +11 -0
  100. package/shared/vendor/paseo-plugin-helper/async.ts +35 -0
  101. package/shared/vendor/paseo-plugin-helper/custom-pills.ts +169 -0
  102. package/shared/vendor/paseo-plugin-helper/forge.ts +110 -0
  103. package/shared/vendor/paseo-plugin-helper/formatters.ts +271 -0
  104. package/shared/vendor/paseo-plugin-helper/highlight.ts +184 -0
  105. package/shared/vendor/paseo-plugin-helper/index.ts +10 -0
  106. package/shared/vendor/paseo-plugin-helper/rpc.ts +72 -0
  107. package/shared/vendor/paseo-plugin-helper/settings.ts +138 -0
  108. package/shared/vendor/paseo-plugin-helper/suite-settings.ts +17 -0
  109. package/shared/vendor/paseo-plugin-helper/suppressed.ts +31 -0
  110. package/shared/vendor/paseo-plugin-helper/types.ts +36 -0
  111. package/shared/version.ts +2 -0
@@ -0,0 +1,486 @@
1
+ import React, { useCallback, useMemo, useState } from "react";
2
+ import { Text, View } from "react-native";
3
+ import { useAgent, type PluginTimelineItemProps } from "@getpaseo/plugin/client";
4
+ import { Icon } from "@getpaseo/plugin/client/react-native";
5
+ import {
6
+ Badge,
7
+ Collapsible,
8
+ CopyButton,
9
+ PluginThemeProvider,
10
+ ProgressBar,
11
+ Row,
12
+ Stack,
13
+ usePluginSettings,
14
+ } from "paseo-plugin-helper/client";
15
+ import { formatBytes, formatUptime, truncatePath } from "paseo-plugin-helper/shared";
16
+ import { buildTelemetryCopyText } from "./telemetry-copy";
17
+ import {
18
+ isTimelineEnabled,
19
+ isMcpSurfaceEnabled,
20
+ topSettingsContract,
21
+ TIMELINE_RENDERED_METRICS,
22
+ type MetricId,
23
+ type TopTimelineTelemetryData,
24
+ } from "../shared/resources";
25
+ import { formatCompactTokens, type TopAgentSnapshot } from "./pill-labels";
26
+
27
+ export { TIMELINE_RENDERED_METRICS };
28
+
29
+ function Vital({
30
+ icon,
31
+ color,
32
+ children,
33
+ }: {
34
+ icon: string;
35
+ color: string;
36
+ children: React.ReactNode;
37
+ }) {
38
+ return (
39
+ <Row gap={4} align="center">
40
+ <Icon name={icon} size={12} color={color} />
41
+ {/*
42
+ * Color MUST be applied to the label as well as the icon. A <Text> with
43
+ * no `color` falls back to React Native's default black and disappears on
44
+ * dark surfaces (xpufx-org/paseo#208). Reusing the icon's `color` keeps
45
+ * threshold states (e.g. CPU/RAM warning/danger) consistent for both.
46
+ */}
47
+ <Text numberOfLines={1} style={{ fontSize: 11, fontWeight: "500", color }}>
48
+ {children}
49
+ </Text>
50
+ </Row>
51
+ );
52
+ }
53
+
54
+ export function TopTimelineTelemetryCard({
55
+ item,
56
+ theme,
57
+ layout,
58
+ timestamp,
59
+ }: PluginTimelineItemProps<TopTimelineTelemetryData>) {
60
+ const data = item.data;
61
+ const [isExpanded, setIsExpanded] = useState(false);
62
+ const liveUsage = useAgent(data.agentId, (a: TopAgentSnapshot) => a.lastUsage);
63
+ const inputTokens = data.inputTokens ?? liveUsage?.inputTokens;
64
+ const outputTokens = data.outputTokens ?? liveUsage?.outputTokens;
65
+ const cachedTokens =
66
+ data.cachedTokens ??
67
+ data.cachedInputTokens ??
68
+ liveUsage?.cachedInputTokens ??
69
+ liveUsage?.cachedTokens;
70
+ const contextUsedTokens =
71
+ data.contextUsedTokens ??
72
+ liveUsage?.contextWindowUsedTokens ??
73
+ liveUsage?.contextUsedTokens;
74
+ const contextMaxTokens =
75
+ data.contextMaxTokens ??
76
+ liveUsage?.contextWindowMaxTokens ??
77
+ liveUsage?.contextMaxTokens;
78
+ const costUsd =
79
+ data.costUsd ?? liveUsage?.totalCostUsd ?? liveUsage?.costUsd;
80
+ const { settings } = usePluginSettings(topSettingsContract);
81
+ const surfaces = settings.metricSurfaces;
82
+ const show = (id: MetricId) => !surfaces || isTimelineEnabled(surfaces[id]);
83
+ const showMcp = isMcpSurfaceEnabled(settings, "timeline", data.mcpInstalled, data.mcpRunning);
84
+
85
+ const outcomeConfig = useMemo(() => {
86
+ switch (data.outcomeKind) {
87
+ case "completed":
88
+ return {
89
+ icon: "CheckCircle2",
90
+ color: theme.colors.statusSuccess,
91
+ label: "Turn Completed",
92
+ };
93
+ case "failed":
94
+ return {
95
+ icon: "AlertCircle",
96
+ color: theme.colors.statusDanger,
97
+ label: "Turn Failed",
98
+ };
99
+ case "canceled":
100
+ return {
101
+ icon: "MinusCircle",
102
+ color: theme.colors.statusWarning,
103
+ label: "Turn Canceled",
104
+ };
105
+ default:
106
+ return {
107
+ icon: "Activity",
108
+ color: theme.colors.foregroundMuted,
109
+ label: "Turn Ended",
110
+ };
111
+ }
112
+ }, [data.outcomeKind, theme.colors]);
113
+
114
+ const cpuColor = useMemo(() => {
115
+ if (data.cpuPercent >= 85) return theme.colors.statusDanger;
116
+ if (data.cpuPercent >= 70) return theme.colors.statusWarning;
117
+ return theme.colors.foreground;
118
+ }, [data.cpuPercent, theme.colors]);
119
+
120
+ const memColor = useMemo(() => {
121
+ if (data.memPercent >= 90) return theme.colors.statusDanger;
122
+ if (data.memPercent >= 75) return theme.colors.statusWarning;
123
+ return theme.colors.foreground;
124
+ }, [data.memPercent, theme.colors]);
125
+
126
+ const timeLabel = useMemo(() => {
127
+ try {
128
+ const d = data.timestamp ? new Date(data.timestamp) : timestamp;
129
+ return d.toLocaleTimeString([], {
130
+ hour: "2-digit",
131
+ minute: "2-digit",
132
+ second: "2-digit",
133
+ });
134
+ } catch {
135
+ return "";
136
+ }
137
+ }, [data.timestamp, timestamp]);
138
+
139
+ const totalTokens =
140
+ inputTokens != null || outputTokens != null
141
+ ? (inputTokens ?? 0) + (outputTokens ?? 0)
142
+ : undefined;
143
+
144
+ const contextPercent = useMemo(() => {
145
+ if (!contextMaxTokens || contextMaxTokens <= 0) return null;
146
+ return Math.round(((contextUsedTokens ?? 0) / contextMaxTokens) * 100);
147
+ }, [contextUsedTokens, contextMaxTokens]);
148
+
149
+ const getCopyText = useCallback(
150
+ () => buildTelemetryCopyText({ data, liveUsage, timeLabel }),
151
+ [data, liveUsage, timeLabel],
152
+ );
153
+
154
+ const hasTokenDetails =
155
+ inputTokens != null ||
156
+ outputTokens != null ||
157
+ cachedTokens != null ||
158
+ contextUsedTokens != null ||
159
+ contextMaxTokens != null ||
160
+ costUsd != null;
161
+
162
+ const canceledText =
163
+ data.outcomeKind === "canceled"
164
+ ? !/^cancel/i.test(data.outcomeError ?? "")
165
+ ? `Canceled${data.outcomeError ? `: ${data.outcomeError}` : ""}`
166
+ : (data.outcomeError ?? "Canceled")
167
+ : "";
168
+
169
+ return (
170
+ <PluginThemeProvider theme={theme} layout={layout}>
171
+ <Collapsible
172
+ variant="elevated"
173
+ isExpanded={isExpanded}
174
+ onToggle={setIsExpanded}
175
+ style={data.outcomeKind === "failed" ? { borderColor: theme.colors.statusDanger } : undefined}
176
+ title={
177
+ <Row gap={6} align="center">
178
+ <Icon name={outcomeConfig.icon} size={14} color={outcomeConfig.color} />
179
+ <Text style={{ fontSize: 12, fontWeight: "600", color: theme.colors.foreground }}>
180
+ {outcomeConfig.label}
181
+ </Text>
182
+ {data.durationMs != null ? (
183
+ <Badge label={`${(data.durationMs / 1000).toFixed(1)}s`} variant="neutral" />
184
+ ) : null}
185
+ </Row>
186
+ }
187
+ headerRight={
188
+ <Row gap={6} align="center">
189
+ {timeLabel !== "" ? (
190
+ <Text style={{ fontSize: 10, color: theme.colors.foregroundMuted }}>{timeLabel}</Text>
191
+ ) : null}
192
+ <Text
193
+ style={{
194
+ fontSize: 10,
195
+ color: theme.colors.foregroundMuted,
196
+ fontStyle: "italic",
197
+ }}
198
+ >
199
+ via top
200
+ </Text>
201
+ {/*
202
+ * Explicit copy affordance. Web's selection-copy handler only
203
+ * rebuilds clipboard content for `[data-testid="assistant-message"]`
204
+ * selections, so styled timeline items copy nothing
205
+ * (xpufx-org/paseo#278). The helper CopyButton bypasses that gate
206
+ * with the helper's own clipboard path. Empty labels keep the
207
+ * header compact: the icon flips Copy -> Check on success.
208
+ */}
209
+ <CopyButton
210
+ getText={getCopyText}
211
+ label=""
212
+ copiedLabel=""
213
+ accessibilityLabel="Copy timeline card"
214
+ toastMessage="timeline card"
215
+ />
216
+ </Row>
217
+ }
218
+ summary={
219
+ !isExpanded && data.outcomeKind === "canceled" ? (
220
+ <Text numberOfLines={2} style={{ fontSize: 11, color: theme.colors.statusDanger }}>
221
+ {canceledText}
222
+ </Text>
223
+ ) : undefined
224
+ }
225
+ >
226
+ <Stack gap={6}>
227
+ <Row wrap gap={8} align="center">
228
+ {show("cpu_ram") && (
229
+ <Vital icon="Cpu" color={cpuColor}>
230
+ CPU {data.cpuPercent}%
231
+ </Vital>
232
+ )}
233
+
234
+ {show("cpu_ram") && (
235
+ <Vital icon="Database" color={memColor}>
236
+ RAM {formatBytes(data.memUsedBytes)} ({data.memPercent}%)
237
+ </Vital>
238
+ )}
239
+
240
+ {show("load") && (
241
+ <Vital icon="Activity" color={theme.colors.foreground}>
242
+ Load {data.loadAvg1m.toFixed(2)}
243
+ </Vital>
244
+ )}
245
+
246
+ {showMcp && (
247
+ <Vital
248
+ icon="Server"
249
+ color={
250
+ data.mcpTotal == null
251
+ ? theme.colors.foregroundMuted
252
+ : (data.mcpHealthy ?? 0) === data.mcpTotal
253
+ ? theme.colors.statusSuccess
254
+ : theme.colors.statusWarning
255
+ }
256
+ >
257
+ {data.mcpTotal != null ? `MCP ${data.mcpHealthy ?? 0}/${data.mcpTotal}` : "MCP --"}
258
+ </Vital>
259
+ )}
260
+
261
+ {show("agent_id") && (
262
+ <Vital icon="Fingerprint" color={theme.colors.foreground}>
263
+ {data.agentId && data.agentId.length > 7
264
+ ? data.agentId.slice(0, 7)
265
+ : (data.agentId ?? "--")}
266
+ </Vital>
267
+ )}
268
+
269
+ {show("agent") && (
270
+ <Vital
271
+ icon="Bot"
272
+ color={data.agentModel ? theme.colors.foreground : theme.colors.foregroundMuted}
273
+ >
274
+ {data.agentModel ?? "model --"}
275
+ </Vital>
276
+ )}
277
+
278
+ {show("agent_provider") && (
279
+ <Vital
280
+ icon="Globe"
281
+ color={
282
+ data.agentProvider ? theme.colors.foreground : theme.colors.foregroundMuted
283
+ }
284
+ >
285
+ {data.agentProvider ?? "provider --"}
286
+ </Vital>
287
+ )}
288
+
289
+ {show("agent_title") && (
290
+ <Vital
291
+ icon="Tag"
292
+ color={data.agentTitle ? theme.colors.foreground : theme.colors.foregroundMuted}
293
+ >
294
+ {data.agentTitle ?? "title --"}
295
+ </Vital>
296
+ )}
297
+
298
+ {show("branch") && (
299
+ <Vital
300
+ icon="GitBranch"
301
+ color={data.branch ? theme.colors.foreground : theme.colors.foregroundMuted}
302
+ >
303
+ {data.branch ?? "branch --"}
304
+ </Vital>
305
+ )}
306
+
307
+ {show("worktree") && (
308
+ <Vital
309
+ icon="Folder"
310
+ color={data.worktree ? theme.colors.foreground : theme.colors.foregroundMuted}
311
+ >
312
+ {data.worktree ? truncatePath(data.worktree, 20) : "worktree --"}
313
+ </Vital>
314
+ )}
315
+
316
+ {show("uptime") && (
317
+ <Vital icon="Clock" color={theme.colors.foreground}>
318
+ {data.uptimeSeconds ? formatUptime(data.uptimeSeconds) : "--"}
319
+ </Vital>
320
+ )}
321
+
322
+ {show("changes") && (
323
+ <Vital icon="GitCommitHorizontal" color={theme.colors.foreground}>
324
+ {(data.gitFilesChanged ?? 0) > 0
325
+ ? `±${data.gitFilesChanged} files +${data.gitInsertions ?? 0}/-${data.gitDeletions ?? 0}`
326
+ : "No changes"}
327
+ </Vital>
328
+ )}
329
+
330
+ {show("tokens") && (
331
+ <Vital
332
+ icon="Coins"
333
+ color={
334
+ totalTokens != null || contextUsedTokens != null
335
+ ? theme.colors.foreground
336
+ : theme.colors.foregroundMuted
337
+ }
338
+ >
339
+ {contextUsedTokens != null && contextMaxTokens
340
+ ? `${formatCompactTokens(contextUsedTokens)}/${formatCompactTokens(contextMaxTokens)}${contextPercent != null ? ` (${contextPercent}% ctx)` : ""}`
341
+ : totalTokens != null
342
+ ? `${formatCompactTokens(totalTokens)} tok`
343
+ : contextUsedTokens != null
344
+ ? `${formatCompactTokens(contextUsedTokens)} ctx`
345
+ : "tok --"}
346
+ </Vital>
347
+ )}
348
+
349
+ {show("tools") && (
350
+ <Vital icon="Sigma" color={theme.colors.foreground}>
351
+ {data.toolCalls != null
352
+ ? `${data.toolCalls}${data.toolErrors ? ` (${data.toolErrors} err)` : ""}`
353
+ : "tools --"}
354
+ </Vital>
355
+ )}
356
+
357
+ {show("turns") && (
358
+ <Vital
359
+ icon="Repeat"
360
+ color={data.turnCount != null ? theme.colors.foreground : theme.colors.foregroundMuted}
361
+ >
362
+ {data.turnCount != null ? `${data.turnCount} turns` : "turns --"}
363
+ </Vital>
364
+ )}
365
+ </Row>
366
+
367
+ {show("tokens") && hasTokenDetails ? (
368
+ <Stack gap={8}>
369
+ <Row justify="space-between" align="center">
370
+ <Row gap={4} align="center">
371
+ <Icon name="Coins" size={12} color={theme.colors.foregroundMuted} />
372
+ <Text style={sectionTitleStyle(theme.colors)}>Tokens & Context</Text>
373
+ </Row>
374
+ {costUsd != null && (
375
+ <Text style={{ fontSize: 10, fontWeight: "600", color: theme.colors.foreground }}>
376
+ ${costUsd < 0.01 ? costUsd.toFixed(4) : costUsd.toFixed(2)}
377
+ </Text>
378
+ )}
379
+ </Row>
380
+
381
+ {contextMaxTokens != null && contextMaxTokens > 0 ? (
382
+ <Stack gap={4}>
383
+ <Row justify="space-between" align="center">
384
+ <Text style={{ fontSize: 10, color: theme.colors.foregroundMuted }}>
385
+ Context Window
386
+ </Text>
387
+ <Text style={{ fontSize: 10, fontWeight: "600", color: theme.colors.foreground }}>
388
+ {formatCompactTokens(contextUsedTokens ?? 0)} /{" "}
389
+ {formatCompactTokens(contextMaxTokens)} ({contextPercent}%)
390
+ </Text>
391
+ </Row>
392
+ <ProgressBar
393
+ value={contextPercent ?? 0}
394
+ thresholds={{ warning: 70, danger: 85 }}
395
+ height={6}
396
+ />
397
+ </Stack>
398
+ ) : contextUsedTokens != null ? (
399
+ <Text style={{ fontSize: 10, color: theme.colors.foreground }}>
400
+ {formatCompactTokens(contextUsedTokens)} tokens
401
+ </Text>
402
+ ) : null}
403
+
404
+ <Row wrap gap={6} align="center">
405
+ {inputTokens != null && (
406
+ <Badge label={`In: ${inputTokens.toLocaleString()}`} variant="neutral" />
407
+ )}
408
+ {outputTokens != null && (
409
+ <Badge label={`Out: ${outputTokens.toLocaleString()}`} variant="neutral" />
410
+ )}
411
+ {cachedTokens != null && (
412
+ <Badge label={`Cache: ${cachedTokens.toLocaleString()}`} variant="neutral" />
413
+ )}
414
+ </Row>
415
+ </Stack>
416
+ ) : show("tokens") ? (
417
+ <Row justify="space-between" align="center">
418
+ <Text style={sectionTitleStyle(theme.colors)}>Tokens & Context</Text>
419
+ <Text
420
+ style={{
421
+ fontSize: 10,
422
+ fontStyle: "italic",
423
+ color: theme.colors.foregroundMuted,
424
+ }}
425
+ >
426
+ Not reported by provider
427
+ </Text>
428
+ </Row>
429
+ ) : null}
430
+
431
+ <Stack gap={4}>
432
+ <Text style={sectionTitleStyle(theme.colors)}>Turn Details</Text>
433
+ <Row wrap gap={12} align="center">
434
+ <Vital icon="Cpu" color={theme.colors.foreground}>
435
+ {data.agentModel ?? "Unknown model"} ({data.agentProvider ?? "default"})
436
+ </Vital>
437
+ {data.toolCalls != null && (
438
+ <Vital icon="Sigma" color={theme.colors.foreground}>
439
+ {data.toolCalls} calls
440
+ {data.toolErrors ? `, ${data.toolErrors} failed` : ""}
441
+ </Vital>
442
+ )}
443
+ {(data.gitInsertions != null || data.gitDeletions != null) && (
444
+ <Vital icon="GitCommitHorizontal" color={theme.colors.foreground}>
445
+ +{data.gitInsertions ?? 0} -{data.gitDeletions ?? 0}
446
+ </Vital>
447
+ )}
448
+ </Row>
449
+ </Stack>
450
+
451
+ {data.outcomeError && (
452
+ <View
453
+ style={{
454
+ padding: 6,
455
+ borderRadius: 4,
456
+ backgroundColor: theme.colors.surface2,
457
+ borderLeftWidth: 3,
458
+ borderLeftColor: theme.colors.statusDanger,
459
+ }}
460
+ >
461
+ <Text numberOfLines={2} style={{ fontSize: 11, color: theme.colors.statusDanger }}>
462
+ {data.outcomeError}
463
+ </Text>
464
+ </View>
465
+ )}
466
+ </Stack>
467
+ </Collapsible>
468
+ </PluginThemeProvider>
469
+ );
470
+ }
471
+
472
+ const styles = {
473
+ // Layout-only. Color MUST come from the theme: a raw style without `color`
474
+ // falls back to React Native's default black and vanishes in dark mode
475
+ // (xpufx-org/paseo#208). Use `sectionTitleStyle(colors)` at call sites.
476
+ sectionTitle: {
477
+ fontSize: 10,
478
+ fontWeight: "600",
479
+ textTransform: "uppercase",
480
+ letterSpacing: 0.5,
481
+ },
482
+ } as const;
483
+
484
+ function sectionTitleStyle(colors: { foregroundMuted: string }) {
485
+ return [styles.sectionTitle, { color: colors.foregroundMuted }];
486
+ }
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Deterministic conversation turn counter.
3
+ *
4
+ * Source of truth is the daemon timeline, reached through the plugin-facing
5
+ * `paseo.agents.ref(id).timeline.refetch()` path (xpufx-org/paseo#248). Each
6
+ * `user_message` entry maps 1:1 to a canonical timeline row, so filtering to
7
+ * `user_message` and sorting by `seqEnd` yields a stable 1-based turn number
8
+ * plus the exact `seq` an agent can resolve against the same timeline. The UI
9
+ * and the agent therefore agree on `Turn #N (seq S)` without a bespoke server.
10
+ *
11
+ * No `@getpaseo/*` import: the input/output shapes are structural so the module
12
+ * stays dependency-free and unit-testable without the Paseo runtime.
13
+ */
14
+
15
+ export interface TimelineEntryLike {
16
+ seqStart?: number;
17
+ seqEnd?: number;
18
+ item?: { type?: string; text?: string } | null;
19
+ }
20
+
21
+ export interface TimelineCursor {
22
+ epoch: string;
23
+ seq: number;
24
+ }
25
+
26
+ export interface TimelinePageLike {
27
+ entries: readonly TimelineEntryLike[];
28
+ hasOlder?: boolean;
29
+ startCursor?: TimelineCursor | null;
30
+ staleCursor?: boolean;
31
+ reset?: boolean;
32
+ }
33
+
34
+ export interface TimelineRefetchOptionsLike {
35
+ direction?: "tail" | "before" | "after";
36
+ cursor?: TimelineCursor;
37
+ limit?: number;
38
+ }
39
+
40
+ export interface AgentTimelineRefLike {
41
+ timeline: {
42
+ refetch(options?: TimelineRefetchOptionsLike): Promise<TimelinePageLike>;
43
+ };
44
+ }
45
+
46
+ export interface PaseoAgentApiLike {
47
+ agents: { ref(agentId: string): AgentTimelineRefLike };
48
+ }
49
+
50
+ export interface TurnRef {
51
+ /** 1-based conversation turn number (Turn #1 is the first user message). */
52
+ turn: number;
53
+ /** Canonical timeline seq of the user_message row; shared with the agent. */
54
+ seq: number;
55
+ /** Verbatim user message text (may be empty). */
56
+ preview: string;
57
+ }
58
+
59
+ /** Default page size while walking history backwards. */
60
+ export const TURN_PAGE_LIMIT = 200;
61
+ /** Safety bound so a pathological timeline cannot loop forever. */
62
+ export const TURN_MAX_PAGES = 50;
63
+
64
+ /**
65
+ * `Turn #3 (seq 12)` — the single address format the panel renders and tests
66
+ * assert, so a human reference and the agent's own resolution cannot drift.
67
+ */
68
+ export function formatTurnAddress(turn: number, seq: number): string {
69
+ return `Turn #${turn} (seq ${seq})`;
70
+ }
71
+
72
+ /** Shortest unambiguous tail of a turn, for list rows and copy actions. */
73
+ export function formatTurnSummary(turn: TurnRef): string {
74
+ return formatTurnAddress(turn.turn, turn.seq);
75
+ }
76
+
77
+ /**
78
+ * Derives 1-based turns from timeline entries. `user_message` rows are filtered
79
+ * and sorted by `seq`, so a caller's page-concatenation order is irrelevant.
80
+ * Entries without a usable numeric seq are dropped rather than silently
81
+ * shifting later turn numbers.
82
+ */
83
+ export function deriveTurns(entries: readonly TimelineEntryLike[]): TurnRef[] {
84
+ const rows: Array<{ seq: number; preview: string }> = [];
85
+ for (const entry of entries) {
86
+ const item = entry?.item;
87
+ if (!item || item.type !== "user_message") continue;
88
+ const seq = typeof entry.seqEnd === "number" ? entry.seqEnd : entry.seqStart;
89
+ if (typeof seq !== "number" || !Number.isFinite(seq)) continue;
90
+ rows.push({ seq, preview: typeof item.text === "string" ? item.text : "" });
91
+ }
92
+ rows.sort((a, b) => a.seq - b.seq);
93
+ return rows.map((row, index) => ({ turn: index + 1, seq: row.seq, preview: row.preview }));
94
+ }
95
+
96
+ /** Current/last turn, or undefined for an empty conversation. */
97
+ export function currentTurn(turns: readonly TurnRef[]): TurnRef | undefined {
98
+ return turns.length > 0 ? turns[turns.length - 1] : undefined;
99
+ }
100
+
101
+ /**
102
+ * Full-history turns via cursor paging. `refetch()` defaults to a 200-entry
103
+ * tail page, so walk backwards with `direction: "before"` using the page's
104
+ * `startCursor` until the daemon reports no older history. A stale/reset epoch
105
+ * (agent replacement) stops the walk and returns what was collected.
106
+ */
107
+ export async function fetchAllTurns(
108
+ paseo: PaseoAgentApiLike,
109
+ agentId: string,
110
+ pageLimit: number = TURN_PAGE_LIMIT,
111
+ ): Promise<TurnRef[]> {
112
+ const handle = paseo.agents.ref(agentId);
113
+ let page = await handle.timeline.refetch({ limit: pageLimit });
114
+ const bySeq = new Map<number, TimelineEntryLike>();
115
+ storeEntries(bySeq, page.entries);
116
+
117
+ let pages = 0;
118
+ while (page.hasOlder && page.startCursor && pages < TURN_MAX_PAGES) {
119
+ const next = await handle.timeline.refetch({
120
+ direction: "before",
121
+ cursor: page.startCursor,
122
+ limit: pageLimit,
123
+ });
124
+ if (next.staleCursor || next.reset) break;
125
+ storeEntries(bySeq, next.entries);
126
+ page = next;
127
+ pages += 1;
128
+ }
129
+ return deriveTurns([...bySeq.values()]);
130
+ }
131
+
132
+ function storeEntries(
133
+ bySeq: Map<number, TimelineEntryLike>,
134
+ entries: readonly TimelineEntryLike[],
135
+ ): void {
136
+ for (const entry of entries) {
137
+ const seq = entrySeq(entry);
138
+ if (seq !== undefined) bySeq.set(seq, entry);
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Stable dedupe key for an entry. `user_message` is not identity-merged, so
144
+ * `seqEnd` is the row seq; fall back to `seqStart` if a host omits it. Returns
145
+ * undefined for a malformed entry so it is dropped, never a bogus `seq -1`.
146
+ */
147
+ function entrySeq(entry: TimelineEntryLike): number | undefined {
148
+ if (typeof entry.seqEnd === "number" && Number.isFinite(entry.seqEnd)) return entry.seqEnd;
149
+ if (typeof entry.seqStart === "number" && Number.isFinite(entry.seqStart)) return entry.seqStart;
150
+ return undefined;
151
+ }