@omercnet/paseo-context-mode 3.1.3 → 3.1.4-next.434.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,295 @@
1
+ import type { PluginTheme } from "@getpaseo/plugin";
2
+ import { Loading } from "@getpaseo/plugin/client/ui";
3
+ import type { ReactNode } from "react";
4
+ import { Pressable, Text, View } from "react-native";
5
+
6
+ /**
7
+ * `primary`: the one accent action on a surface. `secondary`: the default. `outline`: a quiet row
8
+ * action. `destructive`: only the confirm button inside a confirmation.
9
+ */
10
+ export type ButtonVariant = "primary" | "secondary" | "outline" | "destructive";
11
+
12
+ interface ActionButtonProps {
13
+ theme: PluginTheme;
14
+ label: string;
15
+ onPress(): void;
16
+ variant?: ButtonVariant;
17
+ disabled?: boolean;
18
+ accessibilityLabel?: string;
19
+ /** Set for disclosure buttons so assistive tech hears the expanded state. */
20
+ expanded?: boolean;
21
+ }
22
+
23
+ interface ButtonColors {
24
+ background: string;
25
+ border: string;
26
+ text: string;
27
+ }
28
+
29
+ function buttonColors(theme: PluginTheme, variant: ButtonVariant): ButtonColors {
30
+ const { colors } = theme;
31
+ const byVariant: Record<ButtonVariant, ButtonColors> = {
32
+ primary: { background: colors.accent, border: colors.accent, text: colors.accentForeground },
33
+ secondary: { background: colors.surface2, border: colors.surface2, text: colors.foreground },
34
+ outline: { background: "transparent", border: colors.border, text: colors.foreground },
35
+ destructive: {
36
+ background: colors.statusDanger,
37
+ border: colors.statusDanger,
38
+ text: colors.accentForeground,
39
+ },
40
+ };
41
+ return byVariant[variant];
42
+ }
43
+
44
+ export function ActionButton({
45
+ theme,
46
+ label,
47
+ onPress,
48
+ variant = "secondary",
49
+ disabled = false,
50
+ accessibilityLabel,
51
+ expanded,
52
+ }: ActionButtonProps) {
53
+ const colors = buttonColors(theme, variant);
54
+ return (
55
+ <Pressable
56
+ accessibilityLabel={accessibilityLabel ?? label}
57
+ accessibilityRole="button"
58
+ accessibilityState={{ disabled, expanded }}
59
+ disabled={disabled}
60
+ onPress={onPress}
61
+ style={{
62
+ minHeight: 32,
63
+ alignItems: "center",
64
+ justifyContent: "center",
65
+ alignSelf: "flex-start",
66
+ paddingHorizontal: 12,
67
+ paddingVertical: 4,
68
+ borderWidth: 1,
69
+ borderColor: colors.border,
70
+ borderRadius: 999,
71
+ backgroundColor: colors.background,
72
+ opacity: disabled ? 0.5 : 1,
73
+ }}
74
+ >
75
+ <Text style={{ color: colors.text, fontSize: 13, fontWeight: "500" }}>{label}</Text>
76
+ </Pressable>
77
+ );
78
+ }
79
+
80
+ export type StatusTone = "success" | "warning" | "danger" | "neutral";
81
+
82
+ function toneColor(theme: PluginTheme, tone: StatusTone): string {
83
+ const byTone: Record<StatusTone, string> = {
84
+ success: theme.colors.statusSuccess,
85
+ warning: theme.colors.statusWarning,
86
+ danger: theme.colors.statusDanger,
87
+ neutral: theme.colors.foreground,
88
+ };
89
+ return byTone[tone];
90
+ }
91
+
92
+ interface StatusPillProps {
93
+ theme: PluginTheme;
94
+ label: string;
95
+ tone: StatusTone;
96
+ }
97
+
98
+ /** Status text in its status token on the neutral pill shell. */
99
+ export function StatusPill({ theme, label, tone }: StatusPillProps) {
100
+ return (
101
+ <View
102
+ style={{
103
+ alignSelf: "flex-start",
104
+ paddingHorizontal: 8,
105
+ paddingVertical: 4,
106
+ borderWidth: 1,
107
+ borderColor: theme.colors.border,
108
+ borderRadius: 999,
109
+ backgroundColor: theme.colors.surface2,
110
+ }}
111
+ >
112
+ <Text style={{ color: toneColor(theme, tone), fontSize: 12, fontWeight: "400" }}>
113
+ {label}
114
+ </Text>
115
+ </View>
116
+ );
117
+ }
118
+
119
+ export interface ChoiceOption<Value extends string> {
120
+ value: Value;
121
+ label: string;
122
+ }
123
+
124
+ interface ChoicesProps<Value extends string> {
125
+ theme: PluginTheme;
126
+ options: readonly ChoiceOption<Value>[];
127
+ /** Null when nothing is selected yet. */
128
+ value: Value | null;
129
+ onChange(value: Value): void;
130
+ accessibilityLabel(option: ChoiceOption<Value>): string;
131
+ disabled?: boolean;
132
+ /** `tab` renders a tablist; otherwise a radio group. */
133
+ role?: "radio" | "tab";
134
+ }
135
+
136
+ /** Segmented choice: the selected segment is a surface fill with foreground text. */
137
+ export function Choices<Value extends string>({
138
+ theme,
139
+ options,
140
+ value,
141
+ onChange,
142
+ accessibilityLabel,
143
+ disabled = false,
144
+ role = "radio",
145
+ }: ChoicesProps<Value>) {
146
+ return (
147
+ <View
148
+ accessibilityRole={role === "tab" ? "tablist" : "radiogroup"}
149
+ style={{
150
+ flexDirection: "row",
151
+ flexWrap: "wrap",
152
+ alignSelf: role === "tab" ? "stretch" : "flex-start",
153
+ gap: 4,
154
+ padding: 4,
155
+ borderWidth: 1,
156
+ borderColor: theme.colors.border,
157
+ borderRadius: 12,
158
+ backgroundColor: theme.colors.surface1,
159
+ opacity: disabled ? 0.5 : 1,
160
+ }}
161
+ >
162
+ {options.map((option) => (
163
+ <ChoiceSegment
164
+ key={option.value}
165
+ accessibilityLabel={accessibilityLabel(option)}
166
+ disabled={disabled}
167
+ label={option.label}
168
+ onPress={() => onChange(option.value)}
169
+ role={role}
170
+ selected={value === option.value}
171
+ stretch={role === "tab"}
172
+ theme={theme}
173
+ />
174
+ ))}
175
+ </View>
176
+ );
177
+ }
178
+
179
+ interface ChoiceSegmentProps {
180
+ theme: PluginTheme;
181
+ label: string;
182
+ accessibilityLabel: string;
183
+ selected: boolean;
184
+ disabled: boolean;
185
+ stretch: boolean;
186
+ role: "radio" | "tab";
187
+ onPress(): void;
188
+ }
189
+
190
+ function ChoiceSegment({
191
+ theme,
192
+ label,
193
+ accessibilityLabel,
194
+ selected,
195
+ disabled,
196
+ stretch,
197
+ role,
198
+ onPress,
199
+ }: ChoiceSegmentProps) {
200
+ const state = role === "tab" ? { selected, disabled } : { checked: selected, disabled };
201
+ return (
202
+ <Pressable
203
+ accessibilityLabel={accessibilityLabel}
204
+ accessibilityRole={role}
205
+ accessibilityState={state}
206
+ disabled={disabled}
207
+ onPress={onPress}
208
+ style={{
209
+ flexGrow: stretch ? 1 : 0,
210
+ minHeight: 32,
211
+ alignItems: "center",
212
+ justifyContent: "center",
213
+ paddingHorizontal: 12,
214
+ borderRadius: 8,
215
+ backgroundColor: selected ? theme.colors.surface2 : "transparent",
216
+ }}
217
+ >
218
+ <Text
219
+ style={{
220
+ color: selected ? theme.colors.foreground : theme.colors.foregroundMuted,
221
+ fontSize: 13,
222
+ fontWeight: "500",
223
+ }}
224
+ >
225
+ {label}
226
+ </Text>
227
+ </Pressable>
228
+ );
229
+ }
230
+
231
+ interface CardProps {
232
+ theme: PluginTheme;
233
+ children: ReactNode;
234
+ }
235
+
236
+ /** A bordered group of related content. */
237
+ export function Card({ theme, children }: CardProps) {
238
+ return (
239
+ <View
240
+ style={{
241
+ width: "100%",
242
+ gap: 12,
243
+ padding: 16,
244
+ borderWidth: 1,
245
+ borderColor: theme.colors.border,
246
+ borderRadius: 12,
247
+ backgroundColor: theme.colors.surface1,
248
+ }}
249
+ >
250
+ {children}
251
+ </View>
252
+ );
253
+ }
254
+
255
+ interface PendingProps {
256
+ theme: PluginTheme;
257
+ label: string;
258
+ }
259
+
260
+ /** Inline loading: the host spinner beside a short in-flight label. */
261
+ export function Pending({ theme, label }: PendingProps) {
262
+ return (
263
+ <View
264
+ accessibilityLiveRegion="polite"
265
+ style={{ flexDirection: "row", alignItems: "center", gap: 8 }}
266
+ >
267
+ <View style={{ width: 16, height: 16 }}>
268
+ <Loading />
269
+ </View>
270
+ <Text style={{ color: theme.colors.foregroundMuted, fontSize: 12 }}>{label}</Text>
271
+ </View>
272
+ );
273
+ }
274
+
275
+ export function errorMessage(error: unknown): string {
276
+ return error instanceof Error ? error.message : String(error);
277
+ }
278
+
279
+ interface ErrorTextProps {
280
+ theme: PluginTheme;
281
+ error: unknown;
282
+ }
283
+
284
+ /** One-sentence failure under the control it belongs to. Renders nothing without an error. */
285
+ export function ErrorText({ theme, error }: ErrorTextProps) {
286
+ if (!error) return null;
287
+ return (
288
+ <Text
289
+ accessibilityRole="alert"
290
+ style={{ color: theme.colors.statusDanger, fontSize: 12, lineHeight: 16 }}
291
+ >
292
+ {errorMessage(error)}
293
+ </Text>
294
+ );
295
+ }
@@ -0,0 +1,178 @@
1
+ import type { PluginTheme } from "@getpaseo/plugin";
2
+ import { useRpc } from "@getpaseo/plugin/client";
3
+ import {
4
+ ScrollView,
5
+ SettingsAction,
6
+ SettingsCard,
7
+ SettingsSection,
8
+ SettingsSelect,
9
+ } from "@getpaseo/plugin/client/ui";
10
+ import { useMutation } from "@tanstack/react-query";
11
+ import { useState } from "react";
12
+ import { Text, View } from "react-native";
13
+ import {
14
+ type DoctorReport,
15
+ type DoctorStatusRow,
16
+ getContextModeDoctorReport,
17
+ } from "../shared/actions";
18
+ import { CONTEXT_MODE_PROVIDERS, type ContextModeProvider } from "../shared/providers";
19
+ import {
20
+ ActionButton,
21
+ Card,
22
+ type ChoiceOption,
23
+ errorMessage,
24
+ Pending,
25
+ StatusPill,
26
+ type StatusTone,
27
+ } from "./controls";
28
+
29
+ const PROVIDER_OPTIONS: readonly ChoiceOption<ContextModeProvider>[] = CONTEXT_MODE_PROVIDERS.map(
30
+ ({ provider, label }) => ({ value: provider, label }),
31
+ );
32
+
33
+ interface DoctorStatusPresentation {
34
+ label: string;
35
+ tone: StatusTone;
36
+ }
37
+
38
+ const DOCTOR_STATUS: Record<DoctorStatusRow["status"], DoctorStatusPresentation> = {
39
+ ok: { label: "OK", tone: "success" },
40
+ warning: { label: "Warning", tone: "warning" },
41
+ error: { label: "Error", tone: "danger" },
42
+ };
43
+
44
+ interface DiagnosticsSectionProps {
45
+ theme: PluginTheme;
46
+ }
47
+
48
+ export function DiagnosticsSection({ theme }: DiagnosticsSectionProps) {
49
+ const loadDoctor = useRpc(getContextModeDoctorReport);
50
+ const [provider, setProvider] = useState<ContextModeProvider>("claude");
51
+ const doctor = useMutation({
52
+ mutationFn: (target: ContextModeProvider) => loadDoctor({ provider: target }),
53
+ });
54
+
55
+ function changeProvider(next: ContextModeProvider) {
56
+ setProvider(next);
57
+ doctor.reset();
58
+ }
59
+
60
+ return (
61
+ <SettingsSection
62
+ title="Diagnostics"
63
+ info="Doctor runs against a Paseo-owned probe store, so your project data is untouched."
64
+ >
65
+ <View style={{ gap: 16 }}>
66
+ <SettingsCard>
67
+ <SettingsSelect
68
+ label="Provider"
69
+ value={provider}
70
+ options={PROVIDER_OPTIONS}
71
+ disabled={doctor.isPending}
72
+ onValueChange={changeProvider}
73
+ />
74
+ <SettingsAction
75
+ label="Doctor"
76
+ hint="Health checks for the Context Mode runtime"
77
+ actionLabel={doctor.isPending ? "Running..." : "Run doctor"}
78
+ disabled={doctor.isPending}
79
+ error={doctor.error ? errorMessage(doctor.error) : null}
80
+ onPress={() => doctor.mutate(provider)}
81
+ />
82
+ </SettingsCard>
83
+ {doctor.isPending ? <Pending theme={theme} label="Running diagnostics..." /> : null}
84
+ {doctor.data ? <DoctorResult report={doctor.data} theme={theme} /> : null}
85
+ </View>
86
+ </SettingsSection>
87
+ );
88
+ }
89
+
90
+ interface DoctorResultProps {
91
+ report: DoctorReport;
92
+ theme: PluginTheme;
93
+ }
94
+
95
+ function DoctorResult({ report, theme }: DoctorResultProps) {
96
+ const [showRaw, setShowRaw] = useState(false);
97
+ const hasRows = report.rows.length > 0;
98
+ const hasRawOutput = report.rawOutput.trim().length > 0;
99
+ const rawVisible = !hasRows || showRaw;
100
+ const mutedStyle = { color: theme.colors.foregroundMuted, fontSize: 12 };
101
+
102
+ return (
103
+ <Card theme={theme}>
104
+ <View accessibilityLiveRegion="polite" style={{ gap: 12 }}>
105
+ <Text style={mutedStyle}>Checked as {report.platform}</Text>
106
+ {report.rows.map((row) => (
107
+ <DoctorRow key={`${row.status}:${row.check}:${row.detail}`} row={row} theme={theme} />
108
+ ))}
109
+ {hasRows && hasRawOutput ? (
110
+ <ActionButton
111
+ theme={theme}
112
+ variant="outline"
113
+ expanded={showRaw}
114
+ label={showRaw ? "Hide raw diagnostics" : "Show raw diagnostics"}
115
+ onPress={() => setShowRaw((visible) => !visible)}
116
+ />
117
+ ) : null}
118
+ {rawVisible ? <RawOutput output={report.rawOutput} theme={theme} /> : null}
119
+ </View>
120
+ </Card>
121
+ );
122
+ }
123
+
124
+ interface DoctorRowProps {
125
+ row: DoctorStatusRow;
126
+ theme: PluginTheme;
127
+ }
128
+
129
+ function DoctorRow({ row, theme }: DoctorRowProps) {
130
+ const status = DOCTOR_STATUS[row.status];
131
+ const detailStyle = { color: theme.colors.foregroundMuted, fontSize: 12, lineHeight: 16 };
132
+ return (
133
+ <View accessibilityLabel={`${row.check}: ${status.label}. ${row.detail}`} style={{ gap: 4 }}>
134
+ <View style={{ flexDirection: "row", flexWrap: "wrap", alignItems: "center", gap: 8 }}>
135
+ <Text style={{ flexShrink: 1, color: theme.colors.foreground }}>{row.check}</Text>
136
+ <StatusPill theme={theme} label={status.label} tone={status.tone} />
137
+ </View>
138
+ {row.detail ? (
139
+ <Text selectable style={detailStyle}>
140
+ {row.detail}
141
+ </Text>
142
+ ) : null}
143
+ {row.fix ? (
144
+ <Text selectable style={detailStyle}>
145
+ Fix: {row.fix}
146
+ </Text>
147
+ ) : null}
148
+ </View>
149
+ );
150
+ }
151
+
152
+ interface RawOutputProps {
153
+ output: string;
154
+ theme: PluginTheme;
155
+ }
156
+
157
+ function RawOutput({ output, theme }: RawOutputProps) {
158
+ return (
159
+ <View style={{ gap: 4 }}>
160
+ <Text style={{ color: theme.colors.foregroundMuted, fontSize: 12, fontWeight: "500" }}>
161
+ Raw diagnostic output
162
+ </Text>
163
+ <ScrollView nestedScrollEnabled style={{ maxHeight: 220 }}>
164
+ <Text
165
+ selectable
166
+ style={{
167
+ color: theme.colors.foreground,
168
+ fontFamily: "monospace",
169
+ fontSize: 12,
170
+ lineHeight: 16,
171
+ }}
172
+ >
173
+ {output || "No diagnostic text"}
174
+ </Text>
175
+ </ScrollView>
176
+ </View>
177
+ );
178
+ }
@@ -2,11 +2,20 @@ import { type PluginSidebarItemProps, useRpc, useSettings } from "@getpaseo/plug
2
2
  import { SidebarRow } from "@getpaseo/plugin/client/ui";
3
3
  import { useQuery } from "@tanstack/react-query";
4
4
  import { Text } from "react-native";
5
- import { contextModeSettings, getContextModeStatus } from "../shared";
5
+ import { getContextModeStatus } from "../shared/contracts";
6
+ import { contextModeSettings } from "../shared/settings";
6
7
 
7
8
  // The footer stays mounted all session; poll slowly because each status miss spawns the binary.
8
9
  const FOOTER_REFRESH_MS = 60_000;
9
10
 
11
+ type FooterHealth = "failing" | "healthy" | "checking";
12
+
13
+ const HEALTH_LABELS: Record<FooterHealth, string> = {
14
+ failing: "Failing",
15
+ healthy: "Healthy",
16
+ checking: "Checking",
17
+ };
18
+
10
19
  export function ContextModeFooter({
11
20
  theme,
12
21
  host,
@@ -22,13 +31,17 @@ export function ContextModeFooter({
22
31
  staleTime: FOOTER_REFRESH_MS,
23
32
  enabled: settings.status === "ready",
24
33
  });
25
- const failing =
26
- settings.status === "error" ||
27
- settings.status === "invalid" ||
28
- Boolean(status.error) ||
29
- (status.data && status.data.state !== "ready");
30
- const healthy = settings.status === "ready" && !failing && status.data?.state === "ready";
31
- const label = failing ? "Failing" : healthy ? "Healthy" : "Checking";
34
+ const settingsFailed = settings.status === "error" || settings.status === "invalid";
35
+ const statusFailed = Boolean(status.error) || status.data?.state === "unavailable";
36
+ const statusReady = settings.status === "ready" && status.data?.state === "ready";
37
+ let health: FooterHealth = "checking";
38
+ if (settingsFailed || statusFailed) health = "failing";
39
+ else if (statusReady) health = "healthy";
40
+ const healthColors: Record<FooterHealth, string> = {
41
+ failing: theme.colors.statusDanger,
42
+ healthy: theme.colors.statusSuccess,
43
+ checking: theme.colors.foregroundMuted,
44
+ };
32
45
  return (
33
46
  <SidebarRow
34
47
  icon="Gauge"
@@ -36,18 +49,7 @@ export function ContextModeFooter({
36
49
  active={currentScreen?.screenId === "context-mode"}
37
50
  onPress={() => openScreen({ screenId: "context-mode" })}
38
51
  trailing={
39
- <Text
40
- style={{
41
- color: failing
42
- ? theme.colors.statusDanger
43
- : healthy
44
- ? theme.colors.statusSuccess
45
- : theme.colors.foregroundMuted,
46
- fontSize: 12,
47
- }}
48
- >
49
- {label}
50
- </Text>
52
+ <Text style={{ color: healthColors[health], fontSize: 12 }}>{HEALTH_LABELS[health]}</Text>
51
53
  }
52
54
  />
53
55
  );