@yannelli/paseo-linear-plugin 1.0.1 → 1.1.0-alpha.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 (63) hide show
  1. package/OVERVIEW.md +32 -2
  2. package/README.md +134 -8
  3. package/client/access.tsx +67 -0
  4. package/client/browser.tsx +12 -9
  5. package/client/compat.ts +23 -5
  6. package/client/connect.tsx +3 -2
  7. package/client/launch-assignments.tsx +182 -0
  8. package/client/launch-choices.ts +21 -6
  9. package/client/launch-guidance.tsx +103 -0
  10. package/client/launch-plan.ts +114 -17
  11. package/client/launch.tsx +55 -9
  12. package/client/live-agents.tsx +446 -0
  13. package/client/live-feed.tsx +151 -0
  14. package/client/live-header.tsx +310 -0
  15. package/client/live-issues.tsx +345 -0
  16. package/client/live-map.tsx +351 -0
  17. package/client/live-panel.tsx +392 -0
  18. package/client/live-timeline.ts +379 -0
  19. package/client/pill.tsx +394 -0
  20. package/client/plugin-icon.tsx +75 -0
  21. package/client/project-init.tsx +192 -0
  22. package/client/project-knowledge.tsx +87 -0
  23. package/client/settings-access.tsx +56 -0
  24. package/client/settings-agents.tsx +113 -0
  25. package/client/settings-fields.tsx +11 -3
  26. package/client/settings-icon.tsx +277 -0
  27. package/client/settings-live.tsx +128 -0
  28. package/client/settings-overrides.tsx +148 -0
  29. package/client/settings-projects.tsx +26 -4
  30. package/client/settings-prompts.tsx +3 -3
  31. package/client/timeline-card.tsx +2 -1
  32. package/client/tool-buttons.tsx +68 -0
  33. package/client/ui.tsx +16 -7
  34. package/client/web.ts +81 -0
  35. package/index.client.tsx +160 -51
  36. package/index.server.ts +32 -5
  37. package/package.json +1 -1
  38. package/server/agent-hooks.ts +37 -0
  39. package/server/agent-runs.ts +312 -0
  40. package/server/agent-tools.ts +276 -0
  41. package/server/handlers.ts +12 -1
  42. package/server/init.ts +89 -0
  43. package/server/inspect.ts +392 -0
  44. package/server/knowledge.ts +157 -0
  45. package/server/live.ts +371 -0
  46. package/server/mcp-http.ts +156 -0
  47. package/server/stores.ts +126 -0
  48. package/server/subagent-logs.ts +146 -0
  49. package/server/sync.ts +133 -0
  50. package/shared/activity.ts +268 -0
  51. package/shared/agent-hooks.ts +68 -0
  52. package/shared/agent-tools.ts +157 -0
  53. package/shared/custom-icon.ts +188 -0
  54. package/shared/knowledge.ts +255 -0
  55. package/shared/live.ts +248 -0
  56. package/shared/map-model.ts +240 -0
  57. package/shared/project-setup.ts +56 -0
  58. package/shared/prompts.ts +109 -2
  59. package/shared/settings.ts +145 -3
  60. package/shared/shell-activity.ts +309 -0
  61. package/shared/shell-lexer.ts +180 -0
  62. package/shared/subagents.ts +106 -0
  63. package/shared/todo-sync.ts +158 -0
@@ -0,0 +1,87 @@
1
+ import { useRpc } from "@getpaseo/plugin/client";
2
+ import {
3
+ SettingsAction,
4
+ SettingsCard,
5
+ SettingsRow,
6
+ SettingsSection,
7
+ } from "@getpaseo/plugin/client/ui";
8
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
9
+ import {
10
+ areaLabel,
11
+ knowledgeGetRpc,
12
+ knowledgeInspectRpc,
13
+ MAX_KNOWLEDGE_FILES,
14
+ type ProjectBrief,
15
+ } from "../shared/knowledge";
16
+ import { errorMessage, relativeTime } from "./ui";
17
+
18
+ const AREAS_SHOWN = 24;
19
+
20
+ export const knowledgeKey = (projectId: string) => ["linear", "knowledge", projectId] as const;
21
+
22
+ function countText(entries: readonly { name: string; files: number }[]): string {
23
+ return entries.map((entry) => `${entry.name} ${entry.files}`).join(", ");
24
+ }
25
+
26
+ function overview(brief: ProjectBrief): string {
27
+ const source = brief.source === "git" ? "files git tracks" : "files found in the folder";
28
+ const truncated = brief.truncated ? `; the first ${MAX_KNOWLEDGE_FILES.toLocaleString()} are kept` : "";
29
+ return `${brief.fileCount} ${source}${truncated}. Inspected ${relativeTime(brief.inspectedAt)}.`;
30
+ }
31
+
32
+ /** What the daemon knows about the project's files, and a button to inspect it again. */
33
+ export function ProjectKnowledgeSection(props: { projectId: string }) {
34
+ const { projectId } = props;
35
+ const get = useRpc(knowledgeGetRpc);
36
+ const inspect = useRpc(knowledgeInspectRpc);
37
+ const queries = useQueryClient();
38
+ const query = useQuery({
39
+ queryKey: knowledgeKey(projectId),
40
+ queryFn: () => get({ projectId }),
41
+ staleTime: 60_000,
42
+ retry: false,
43
+ });
44
+ const again = useMutation({
45
+ mutationFn: () => inspect({ projectId }),
46
+ onSuccess: (data) => queries.setQueryData(knowledgeKey(projectId), data),
47
+ });
48
+ const brief = query.data?.knowledge ?? null;
49
+ const busy = query.isPending || again.isPending;
50
+ const failure = again.error ?? query.error;
51
+ const areas = brief?.areas.filter((area) => area.path !== "") ?? [];
52
+ return (
53
+ <SettingsSection title="Project knowledge">
54
+ <SettingsCard>
55
+ <SettingsAction
56
+ label="Inspect the project"
57
+ hint="The daemon lists the project's files and reads its manifests to find its services, packages, and main folders. No agent runs. Explore and setup agents, ticket-text maps, and Linear Live use what it finds. It inspects again when the knowledge is 6 hours old."
58
+ actionLabel={busy ? "Inspecting…" : "Inspect again"}
59
+ disabled={busy}
60
+ onPress={() => again.mutate()}
61
+ />
62
+ {failure ? <SettingsRow label="Inspection did not finish" error={errorMessage(failure)} /> : null}
63
+ {brief ? (
64
+ <>
65
+ <SettingsRow label="Files" hint={overview(brief)} />
66
+ {brief.summary ? <SettingsRow label="Project" hint={brief.summary} /> : null}
67
+ {brief.languages.length > 0 ? (
68
+ <SettingsRow label="Languages" hint={countText(brief.languages)} />
69
+ ) : null}
70
+ {brief.commands.length > 0 ? (
71
+ <SettingsRow label="Commands" hint={brief.commands.join(", ")} />
72
+ ) : null}
73
+ {brief.guides.length > 0 ? <SettingsRow label="Guides" hint={brief.guides.join(", ")} /> : null}
74
+ {areas.slice(0, AREAS_SHOWN).map((area) => {
75
+ const files = `${area.files} ${area.files === 1 ? "file" : "files"}`;
76
+ const parts = [areaLabel(area), files, area.languages.join("/"), area.summary];
77
+ return <SettingsRow key={area.path} label={area.path} hint={parts.filter(Boolean).join(" · ")} />;
78
+ })}
79
+ {areas.length > AREAS_SHOWN ? (
80
+ <SettingsRow label={`${areas.length - AREAS_SHOWN} more areas`} />
81
+ ) : null}
82
+ </>
83
+ ) : null}
84
+ </SettingsCard>
85
+ </SettingsSection>
86
+ );
87
+ }
@@ -0,0 +1,56 @@
1
+ import { SettingsCard, SettingsSection, SettingsSwitch } from "@getpaseo/plugin/client/ui";
2
+ import { useEffect } from "react";
3
+ import {
4
+ type LinearSettings,
5
+ type ProjectAccess,
6
+ projectEnabled,
7
+ withProjectAccess,
8
+ } from "../shared/settings";
9
+ import { publishAccess } from "./access";
10
+ import type { PaseoProjectOption } from "./queries";
11
+
12
+ type Edit = (change: (current: LinearSettings) => LinearSettings) => void;
13
+
14
+ /** Where Linear works: one switch for every project, then one per project. */
15
+ export function AccessSection(props: {
16
+ access: ProjectAccess;
17
+ saved: ProjectAccess;
18
+ projects: readonly PaseoProjectOption[] | undefined;
19
+ edit: Edit;
20
+ }) {
21
+ const { access, saved, edit } = props;
22
+ useEffect(() => publishAccess(saved), [saved]);
23
+ const setAccess = (next: (current: ProjectAccess) => ProjectAccess) =>
24
+ edit((current) => ({ ...current, access: next(current.access) }));
25
+ return (
26
+ <SettingsSection title="Use Linear in">
27
+ <SettingsCard>
28
+ <SettingsSwitch
29
+ label="All projects"
30
+ hint={
31
+ access.allProjects
32
+ ? "On everywhere. Turn off a project below to hide Linear there."
33
+ : "Off everywhere. Turn on a project below to use Linear there."
34
+ }
35
+ value={access.allProjects}
36
+ onValueChange={(allProjects) => setAccess((current) => ({ ...current, allProjects }))}
37
+ />
38
+ {(props.projects ?? []).map((project) => (
39
+ <SettingsSwitch
40
+ key={project.projectId}
41
+ label={project.displayName}
42
+ hint={
43
+ access.projects[project.projectId] === undefined
44
+ ? "Follows All projects"
45
+ : project.rootPath
46
+ }
47
+ value={projectEnabled(access, project.projectId)}
48
+ onValueChange={(enabled) =>
49
+ setAccess((current) => withProjectAccess(current, project.projectId, enabled))
50
+ }
51
+ />
52
+ ))}
53
+ </SettingsCard>
54
+ </SettingsSection>
55
+ );
56
+ }
@@ -0,0 +1,113 @@
1
+ import { type PluginSurfaceProps, useSettings } from "@getpaseo/plugin/client";
2
+ import { SettingsCard, SettingsRow, SettingsSection, SettingsSelect, SettingsSwitch } from "@getpaseo/plugin/client/ui";
3
+ import { useCallback } from "react";
4
+ import { View } from "react-native";
5
+ import { linearSettings, MAX_AGENTS_LIMIT, type ToolSettings } from "../shared/settings";
6
+ import { type ReadySettings, SaveBar, SettingsGate, useSettingsDraft } from "./settings-fields";
7
+ import type { Theme } from "./ui";
8
+
9
+ // Settings for all projects. A project can change each of them in Linear settings, under
10
+ // Projects.
11
+
12
+ const LIMITS = [0, 1, 2, 3, 4, 6, 8, 12, MAX_AGENTS_LIMIT];
13
+
14
+ export const limitLabel = (limit: number) => (limit === 0 ? "No limit" : limit === 1 ? "1 agent" : `${limit} agents`);
15
+
16
+ export const LIMIT_OPTIONS = LIMITS.map((limit) => ({ label: limitLabel(limit), value: String(limit) }));
17
+
18
+ export const COPY = {
19
+ enabled: {
20
+ label: "Give agents the Linear tools",
21
+ hint: "Agents started from an issue get the plugin's linear MCP server when Keep Linear updated, Hand off work to Paseo agents, or sub-issue agents need it. When it is off, agents that have the server get no tools from it.",
22
+ },
23
+ allowEdits: {
24
+ label: "Let agents edit issue descriptions",
25
+ hint: "The edit_issue tool, which checks off task list items and updates the plan. When it is off, agents can only read the issue and its sub-issues.",
26
+ },
27
+ assignAgents: {
28
+ label: "Choose agents for sub-issues",
29
+ hint: "The agent setup page lists the sub-issues, and you can choose an agent, model, and thinking for each one. The agent you start hands those sub-issues to them.",
30
+ },
31
+ maxAgents: {
32
+ label: "Agents at the same time",
33
+ hint: "How many agents one launch can run at the same time with start_agent. It refuses to start more until one of them finishes.",
34
+ },
35
+ } as const;
36
+
37
+ export function AgentSettings({ theme }: PluginSurfaceProps) {
38
+ const settings = useSettings(linearSettings);
39
+ const render = useCallback(
40
+ (ready: ReadySettings) => <AgentEditor theme={theme} settings={ready} />,
41
+ [theme],
42
+ );
43
+ return (
44
+ <SettingsGate theme={theme} title="Agents" settings={settings}>
45
+ {render}
46
+ </SettingsGate>
47
+ );
48
+ }
49
+
50
+ function AgentEditor({ theme, settings }: { theme: Theme; settings: ReadySettings }) {
51
+ const { values, dirty, edit, save, discard } = useSettingsDraft(settings);
52
+ const { tools } = values;
53
+ const change = useCallback(
54
+ (patch: Partial<ToolSettings>) => edit((current) => ({ ...current, tools: { ...current.tools, ...patch } })),
55
+ [edit],
56
+ );
57
+ return (
58
+ <View>
59
+ <SettingsSection title="Linear tools">
60
+ <SettingsCard>
61
+ <SettingsSwitch
62
+ label={COPY.enabled.label}
63
+ hint={COPY.enabled.hint}
64
+ value={tools.enabled}
65
+ onValueChange={(enabled) => change({ enabled })}
66
+ />
67
+ <SettingsSwitch
68
+ label={COPY.allowEdits.label}
69
+ hint={COPY.allowEdits.hint}
70
+ value={tools.allowEdits}
71
+ disabled={!tools.enabled}
72
+ onValueChange={(allowEdits) => change({ allowEdits })}
73
+ />
74
+ </SettingsCard>
75
+ </SettingsSection>
76
+ <SettingsSection title="Agents on sub-issues">
77
+ <SettingsCard>
78
+ <SettingsSwitch
79
+ label={COPY.assignAgents.label}
80
+ hint={COPY.assignAgents.hint}
81
+ value={tools.assignAgents}
82
+ disabled={!tools.enabled}
83
+ onValueChange={(assignAgents) => change({ assignAgents })}
84
+ />
85
+ <SettingsSelect
86
+ label={COPY.maxAgents.label}
87
+ hint={COPY.maxAgents.hint}
88
+ value={String(tools.maxAgents)}
89
+ options={LIMIT_OPTIONS}
90
+ disabled={!tools.enabled}
91
+ onValueChange={(value) => change({ maxAgents: Number(value) })}
92
+ />
93
+ </SettingsCard>
94
+ </SettingsSection>
95
+ <SettingsSection title="Projects">
96
+ <SettingsCard>
97
+ <SettingsRow
98
+ label="Values for one project"
99
+ hint="A project can use its own values for these settings and for the agent defaults. Set them in Linear settings, under Projects."
100
+ />
101
+ </SettingsCard>
102
+ </SettingsSection>
103
+ <SaveBar
104
+ theme={theme}
105
+ dirty={dirty}
106
+ saving={settings.saving}
107
+ error={settings.saveError}
108
+ onSave={() => void save()}
109
+ onDiscard={discard}
110
+ />
111
+ </View>
112
+ );
113
+ }
@@ -5,6 +5,7 @@ import { type ReactNode, useCallback, useMemo, useState } from "react";
5
5
  import { Text, View } from "react-native";
6
6
  import { TEMPLATE_VARIABLES } from "../shared/prompts";
7
7
  import type { LinearSettings, linearSettings } from "../shared/settings";
8
+ import { MONO } from "./live-issues";
8
9
  import type { Theme } from "./ui";
9
10
 
10
11
  export type ReadySettings = Extract<
@@ -19,10 +20,14 @@ export function MultilineField(props: {
19
20
  value: string;
20
21
  placeholder?: string;
21
22
  minHeight?: number;
23
+ error?: string | null;
24
+ /** Markup, not prose: a monospace font and no autocorrect. */
25
+ code?: boolean;
22
26
  onChange(value: string): void;
23
27
  }) {
24
28
  const { colors } = props.theme;
25
29
  const minHeight = props.minHeight ?? 96;
30
+ const { code } = props;
26
31
  const style = useMemo(
27
32
  () => ({
28
33
  marginTop: 8,
@@ -36,11 +41,12 @@ export function MultilineField(props: {
36
41
  fontSize: 13,
37
42
  lineHeight: 19,
38
43
  textAlignVertical: "top" as const,
44
+ ...(code ? { fontFamily: MONO, fontSize: 12 } : {}),
39
45
  }),
40
- [colors, minHeight],
46
+ [colors, minHeight, code],
41
47
  );
42
48
  return (
43
- <SettingsRow label={props.label} hint={props.hint}>
49
+ <SettingsRow label={props.label} hint={props.hint} error={props.error}>
44
50
  <TextInput
45
51
  value={props.value}
46
52
  onChangeText={props.onChange}
@@ -48,7 +54,9 @@ export function MultilineField(props: {
48
54
  placeholder={props.placeholder}
49
55
  placeholderTextColor={colors.foregroundMuted}
50
56
  accessibilityLabel={props.label}
51
- autoCapitalize="sentences"
57
+ autoCapitalize={code ? "none" : "sentences"}
58
+ autoCorrect={!code}
59
+ spellCheck={!code}
52
60
  style={style}
53
61
  />
54
62
  </SettingsRow>
@@ -0,0 +1,277 @@
1
+ import { type PluginSurfaceProps, useSettings } from "@getpaseo/plugin/client";
2
+ import {
3
+ SettingsAction,
4
+ SettingsCard,
5
+ SettingsInput,
6
+ SettingsRow,
7
+ SettingsSection,
8
+ SettingsSelect,
9
+ SettingsSwitch,
10
+ } from "@getpaseo/plugin/client/ui";
11
+ import { Icon } from "@getpaseo/plugin/client/react-native";
12
+ import { useCallback, useMemo, useState } from "react";
13
+ import { Platform, Text, View } from "react-native";
14
+ import {
15
+ HEX_COLOR,
16
+ type IconPaint,
17
+ type IconSettings,
18
+ MAX_SVG_LENGTH,
19
+ MENU_ICONS,
20
+ parseIconSvg,
21
+ } from "../shared/custom-icon";
22
+ import { linearSettings } from "../shared/settings";
23
+ import { IconGlyph, useIconSettings } from "./plugin-icon";
24
+ import { MultilineField, type ReadySettings, SaveBar, SettingsGate, useSettingsDraft } from "./settings-fields";
25
+ import type { Theme } from "./ui";
26
+ import { canPickFiles, pickTextFile } from "./web";
27
+
28
+ const PAINT_OPTIONS: readonly { label: string; value: IconPaint }[] = [
29
+ { label: "Original colors", value: "original" },
30
+ { label: "Theme color, like Paseo's icons", value: "theme" },
31
+ { label: "Theme accent", value: "accent" },
32
+ { label: "Linear indigo, the plugin color", value: "linear" },
33
+ { label: "Custom color", value: "custom" },
34
+ ];
35
+ const PREVIEW_SIZES = [14, 16, 22, 28] as const;
36
+ const SVG_FILES = ".svg,image/svg+xml";
37
+ /** Exports often carry comments and metadata, so the file may be larger than the saved SVG. */
38
+ const MAX_FILE_BYTES = MAX_SVG_LENGTH * 4;
39
+
40
+ export function IconSettingsScreen({ theme }: PluginSurfaceProps) {
41
+ const settings = useSettings(linearSettings);
42
+ useIconSettings();
43
+ const render = useCallback(
44
+ (ready: ReadySettings) => <IconEditor theme={theme} settings={ready} />,
45
+ [theme],
46
+ );
47
+ return (
48
+ <SettingsGate theme={theme} title="Icon" settings={settings}>
49
+ {render}
50
+ </SettingsGate>
51
+ );
52
+ }
53
+
54
+ function IconPreview({ theme, icon }: { theme: Theme; icon: IconSettings }) {
55
+ const { colors } = theme;
56
+ const styles = useMemo(
57
+ () => ({
58
+ row: { flexDirection: "row" as const, flexWrap: "wrap" as const, alignItems: "center" as const, gap: 14, paddingTop: 8 },
59
+ pill: {
60
+ flexDirection: "row" as const,
61
+ alignItems: "center" as const,
62
+ gap: 6,
63
+ height: 26,
64
+ paddingHorizontal: 10,
65
+ borderRadius: 13,
66
+ borderWidth: 1,
67
+ borderColor: colors.border,
68
+ backgroundColor: colors.surface1,
69
+ },
70
+ label: { color: colors.foreground, fontSize: 12 },
71
+ }),
72
+ [colors],
73
+ );
74
+ return (
75
+ <View style={styles.row}>
76
+ {PREVIEW_SIZES.map((size) => (
77
+ <IconGlyph key={size} icon={icon} size={size} color={colors.foregroundMuted} theme={theme} />
78
+ ))}
79
+ <View style={styles.pill}>
80
+ <IconGlyph icon={icon} size={14} color={colors.foregroundMuted} theme={theme} />
81
+ <Text style={styles.label}>ENG-123</Text>
82
+ </View>
83
+ <View style={styles.pill}>
84
+ <IconGlyph icon={icon} size={14} color={colors.foreground} theme={theme} />
85
+ <Text style={styles.label}>Linear</Text>
86
+ </View>
87
+ </View>
88
+ );
89
+ }
90
+
91
+ function MenuPreview({ theme, name }: { theme: Theme; name: string }) {
92
+ const { colors } = theme;
93
+ const styles = useMemo(
94
+ () => ({
95
+ tab: {
96
+ flexDirection: "row" as const,
97
+ alignSelf: "flex-start" as const,
98
+ alignItems: "center" as const,
99
+ gap: 6,
100
+ marginTop: 8,
101
+ paddingHorizontal: 10,
102
+ paddingVertical: 5,
103
+ borderRadius: 6,
104
+ backgroundColor: colors.surface2,
105
+ },
106
+ label: { color: colors.foreground, fontSize: 12 },
107
+ }),
108
+ [colors],
109
+ );
110
+ return (
111
+ <View style={styles.tab}>
112
+ <Icon name={name} size={14} color={colors.foregroundMuted} />
113
+ <Text style={styles.label}>Linear</Text>
114
+ </View>
115
+ );
116
+ }
117
+
118
+ function IconEditor({ theme, settings }: { theme: Theme; settings: ReadySettings }) {
119
+ const { values, dirty, edit, save, discard } = useSettingsDraft(settings);
120
+ const icon = values.icon;
121
+ const [markup, setMarkup] = useState(icon.svg);
122
+ const [markupError, setMarkupError] = useState<string | null>(null);
123
+ const [colorError, setColorError] = useState<string | null>(null);
124
+ const [fileName, setFileName] = useState<string | null>(null);
125
+ const change = useCallback(
126
+ (patch: Partial<IconSettings>) => edit((current) => ({ ...current, icon: { ...current.icon, ...patch } })),
127
+ [edit],
128
+ );
129
+ // The draft keeps the last SVG that passed the checks, so the preview never breaks.
130
+ const applyMarkup = useCallback(
131
+ (text: string) => {
132
+ setMarkup(text);
133
+ if (!text.trim()) {
134
+ setMarkupError(null);
135
+ change({ svg: "" });
136
+ return;
137
+ }
138
+ const parsed = parseIconSvg(text);
139
+ setMarkupError("error" in parsed ? parsed.error : null);
140
+ if ("svg" in parsed) change({ svg: parsed.svg });
141
+ },
142
+ [change],
143
+ );
144
+ const choose = useCallback(
145
+ () =>
146
+ pickTextFile(SVG_FILES, MAX_FILE_BYTES, (file) => {
147
+ if ("error" in file) {
148
+ setMarkupError(file.error);
149
+ return;
150
+ }
151
+ setFileName(file.name);
152
+ applyMarkup(file.text);
153
+ }),
154
+ [applyMarkup],
155
+ );
156
+ const removeSvg = useCallback(() => {
157
+ setFileName(null);
158
+ applyMarkup("");
159
+ }, [applyMarkup]);
160
+ const changeColor = useCallback(
161
+ (text: string) => {
162
+ const color = text.trim();
163
+ const valid = HEX_COLOR.test(color);
164
+ setColorError(valid ? null : "Use a hex color, such as #5E6AD2.");
165
+ if (valid) change({ color });
166
+ },
167
+ [change],
168
+ );
169
+ const reset = useCallback(() => {
170
+ discard();
171
+ setMarkup(settings.values.icon.svg);
172
+ setMarkupError(null);
173
+ setColorError(null);
174
+ setFileName(null);
175
+ }, [discard, settings.values.icon.svg]);
176
+ const native = Platform.OS !== "web";
177
+ const custom = icon.svg !== "";
178
+ const menuOptions = useMemo(() => {
179
+ const options = MENU_ICONS.map((entry) => ({ label: entry.label, value: entry.name }));
180
+ if (!options.some((option) => option.value === icon.menu)) options.push({ label: icon.menu, value: icon.menu });
181
+ return options;
182
+ }, [icon.menu]);
183
+
184
+ return (
185
+ <View>
186
+ <SettingsSection title="Plugin icon">
187
+ <SettingsCard>
188
+ <SettingsRow
189
+ label="Preview"
190
+ hint={
191
+ native
192
+ ? "The iOS and Android apps cannot draw plugin SVG images, so they show the built-in icon in the color you choose."
193
+ : "Composer pills, the Linear sidebar row, and Linear screens use this icon. Panel tabs and menus use the built-in icon below."
194
+ }
195
+ >
196
+ <IconPreview theme={theme} icon={icon} />
197
+ </SettingsRow>
198
+ {canPickFiles ? (
199
+ <SettingsAction
200
+ label="SVG file"
201
+ hint={fileName ? `Loaded ${fileName}. Save to use it.` : "Choose an .svg file. The plugin saves a copy in its settings."}
202
+ actionLabel="Choose file"
203
+ onPress={choose}
204
+ />
205
+ ) : null}
206
+ <MultilineField
207
+ theme={theme}
208
+ label="SVG markup"
209
+ hint="Paste SVG markup, or edit it here. Scripts, links to other files, and bitmap images are not allowed. Clear it to use the built-in icon."
210
+ value={markup}
211
+ placeholder={'<svg viewBox="0 0 24 24">…</svg>'}
212
+ error={markupError}
213
+ code
214
+ onChange={applyMarkup}
215
+ />
216
+ <SettingsAction
217
+ label="Remove SVG"
218
+ hint="Use the built-in icon everywhere."
219
+ actionLabel="Remove"
220
+ disabled={!custom && markup === ""}
221
+ onPress={removeSvg}
222
+ />
223
+ </SettingsCard>
224
+ </SettingsSection>
225
+ <SettingsSection title="Color and fill">
226
+ <SettingsCard>
227
+ <SettingsSelect
228
+ label="Color"
229
+ hint="Original colors keep the SVG as drawn, and currentColor follows Paseo. The other choices paint the whole icon one color."
230
+ value={icon.paint}
231
+ options={PAINT_OPTIONS}
232
+ onValueChange={(paint) => change({ paint })}
233
+ />
234
+ {icon.paint === "custom" ? (
235
+ <SettingsInput
236
+ label="Custom color"
237
+ hint="A hex color, such as #5E6AD2 or #5E6AD2CC with transparency."
238
+ initialValue={icon.color}
239
+ placeholder="#5E6AD2"
240
+ error={colorError}
241
+ onChangeText={changeColor}
242
+ />
243
+ ) : null}
244
+ <SettingsSwitch
245
+ label="Solid"
246
+ hint={custom ? "Fill shapes that only have an outline, so line icons show as solid shapes." : "Add an SVG to use this. Built-in icons stay as drawn."}
247
+ value={icon.solid}
248
+ disabled={!custom}
249
+ onValueChange={(solid) => change({ solid })}
250
+ />
251
+ </SettingsCard>
252
+ </SettingsSection>
253
+ <SettingsSection title="Built-in icon">
254
+ <SettingsCard>
255
+ <SettingsSelect
256
+ label="Built-in icon"
257
+ hint="Panel tabs, the new tab menu, and Command Center take built-in icons only, so they use this one. Without an SVG, every place uses it. Tabs that are open change when you reload Paseo or open them again."
258
+ value={icon.menu}
259
+ options={menuOptions}
260
+ onValueChange={(menu) => change({ menu })}
261
+ />
262
+ <SettingsRow label="Tab preview">
263
+ <MenuPreview theme={theme} name={icon.menu} />
264
+ </SettingsRow>
265
+ </SettingsCard>
266
+ </SettingsSection>
267
+ <SaveBar
268
+ theme={theme}
269
+ dirty={dirty}
270
+ saving={settings.saving}
271
+ error={settings.saveError}
272
+ onSave={() => void save()}
273
+ onDiscard={reset}
274
+ />
275
+ </View>
276
+ );
277
+ }