@yannelli/paseo-linear-plugin 0.1.0 → 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.
- package/NOTICE +8 -4
- package/OVERVIEW.md +23 -13
- package/README.md +138 -29
- package/client/agent-options.ts +200 -0
- package/client/autosave.ts +77 -0
- package/client/breadcrumbs.tsx +110 -0
- package/client/browser.tsx +275 -0
- package/client/compat.ts +107 -0
- package/client/connect.tsx +136 -0
- package/client/create-issue.tsx +291 -0
- package/client/glyphs.tsx +117 -0
- package/client/issue-description.tsx +206 -0
- package/client/issue-detail.tsx +358 -0
- package/client/issue-list.tsx +373 -0
- package/client/issue-row.tsx +152 -0
- package/client/issue-sections.tsx +325 -0
- package/client/issue-tree.ts +132 -0
- package/client/key-scope.tsx +23 -0
- package/client/launch-choices.ts +125 -0
- package/client/launch-fields.tsx +152 -0
- package/client/launch-plan.ts +227 -0
- package/client/launch.tsx +484 -0
- package/client/markdown.tsx +302 -0
- package/client/model-browser.tsx +296 -0
- package/client/pickers.tsx +238 -0
- package/client/provider-icon.tsx +78 -0
- package/client/queries.ts +242 -0
- package/client/settings-connection.tsx +109 -0
- package/client/settings-fields.tsx +173 -0
- package/client/settings-keys.tsx +117 -0
- package/client/settings-projects.tsx +281 -0
- package/client/settings-prompts.tsx +198 -0
- package/client/store.ts +71 -0
- package/client/timeline-card.tsx +63 -0
- package/client/ui.tsx +352 -0
- package/index.client.tsx +111 -0
- package/index.server.ts +7 -3
- package/package.json +4 -3
- package/paseo-plugin.json +2 -2
- package/server/cache.ts +68 -0
- package/server/credentials.ts +101 -0
- package/server/graphql.ts +68 -0
- package/server/handlers.ts +172 -0
- package/server/linear.ts +11 -64
- package/server/queries.ts +280 -0
- package/shared/linear.ts +268 -0
- package/shared/markdown.ts +461 -0
- package/shared/prompts.ts +150 -0
- package/shared/settings.ts +68 -0
- package/index.client.ts +0 -7
- package/server/issues.ts +0 -10
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import { openExternalUrl } from "@getpaseo/plugin/client";
|
|
2
|
+
import { Icon } from "@getpaseo/plugin/client/react-native";
|
|
3
|
+
import { type ReactNode, useCallback, useMemo } from "react";
|
|
4
|
+
import {
|
|
5
|
+
Platform,
|
|
6
|
+
Pressable,
|
|
7
|
+
ScrollView,
|
|
8
|
+
type StyleProp,
|
|
9
|
+
Text,
|
|
10
|
+
type TextStyle,
|
|
11
|
+
View,
|
|
12
|
+
} from "react-native";
|
|
13
|
+
import {
|
|
14
|
+
type MarkdownBlock,
|
|
15
|
+
type MarkdownInline,
|
|
16
|
+
type MarkdownListItem,
|
|
17
|
+
parseMarkdown,
|
|
18
|
+
taskItems,
|
|
19
|
+
} from "../shared/markdown";
|
|
20
|
+
import type { Theme } from "./ui";
|
|
21
|
+
|
|
22
|
+
type ListBlock = Extract<MarkdownBlock, { type: "list" }>;
|
|
23
|
+
type TableBlock = Extract<MarkdownBlock, { type: "table" }>;
|
|
24
|
+
|
|
25
|
+
const MONO = Platform.select({
|
|
26
|
+
ios: "Menlo",
|
|
27
|
+
android: "monospace",
|
|
28
|
+
default: "ui-monospace, SFMono-Regular, Menlo, monospace",
|
|
29
|
+
});
|
|
30
|
+
const LINE_HEIGHT = 21;
|
|
31
|
+
const HEADING_SIZES = [20, 18, 16, 15, 14, 14];
|
|
32
|
+
const COMPACT_HEADING_SIZES = [18, 16, 15, 14, 14, 14];
|
|
33
|
+
|
|
34
|
+
function createStyles(colors: Theme["colors"], compact: boolean, muted: boolean) {
|
|
35
|
+
const ink = muted ? colors.foregroundMuted : colors.foreground;
|
|
36
|
+
const text = { color: ink, fontSize: 14, lineHeight: LINE_HEIGHT } as const;
|
|
37
|
+
const cell = {
|
|
38
|
+
minHeight: LINE_HEIGHT + 12,
|
|
39
|
+
paddingHorizontal: 10,
|
|
40
|
+
paddingVertical: 6,
|
|
41
|
+
justifyContent: "center",
|
|
42
|
+
} as const;
|
|
43
|
+
const column = { flexDirection: "column" } as const;
|
|
44
|
+
return {
|
|
45
|
+
ink,
|
|
46
|
+
text,
|
|
47
|
+
root: { gap: compact ? 8 : 10 },
|
|
48
|
+
headings: (compact ? COMPACT_HEADING_SIZES : HEADING_SIZES).map((fontSize) => {
|
|
49
|
+
const lineHeight = Math.round(fontSize * 1.4);
|
|
50
|
+
return { color: ink, fontSize, lineHeight, fontWeight: "600" } as const;
|
|
51
|
+
}),
|
|
52
|
+
strong: { fontWeight: "600" },
|
|
53
|
+
emphasis: { fontStyle: "italic" },
|
|
54
|
+
strike: { textDecorationLine: "line-through" },
|
|
55
|
+
link: { color: colors.foreground, textDecorationLine: "underline" },
|
|
56
|
+
code: { fontFamily: MONO, backgroundColor: colors.surface2, borderRadius: 4 },
|
|
57
|
+
codeBlock: {
|
|
58
|
+
backgroundColor: colors.surface1,
|
|
59
|
+
borderWidth: 1,
|
|
60
|
+
borderColor: colors.border,
|
|
61
|
+
borderRadius: 8,
|
|
62
|
+
},
|
|
63
|
+
codeContent: { padding: 10 },
|
|
64
|
+
codeText: { fontFamily: MONO, fontSize: 13, lineHeight: 19, color: colors.foreground },
|
|
65
|
+
quote: { borderLeftWidth: 3, borderLeftColor: colors.border, paddingLeft: 10, gap: 8 },
|
|
66
|
+
rule: { height: 1, backgroundColor: colors.border, marginVertical: 4 },
|
|
67
|
+
list: { gap: 4 },
|
|
68
|
+
item: { flexDirection: "row", gap: 4 },
|
|
69
|
+
marker: { minWidth: 12, height: LINE_HEIGHT, justifyContent: "center" },
|
|
70
|
+
itemBody: { flex: 1, minWidth: 0, gap: 4 },
|
|
71
|
+
table: {
|
|
72
|
+
flexDirection: "row",
|
|
73
|
+
borderWidth: 1,
|
|
74
|
+
borderColor: colors.border,
|
|
75
|
+
borderRadius: 6,
|
|
76
|
+
overflow: "hidden",
|
|
77
|
+
},
|
|
78
|
+
column,
|
|
79
|
+
columnDivided: { ...column, borderLeftWidth: 1, borderLeftColor: colors.border },
|
|
80
|
+
headCell: { ...cell, backgroundColor: colors.surface1 },
|
|
81
|
+
cell: { ...cell, borderTopWidth: 1, borderTopColor: colors.border },
|
|
82
|
+
headText: { ...text, fontWeight: "600" },
|
|
83
|
+
} as const;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
type Styles = ReturnType<typeof createStyles>;
|
|
87
|
+
|
|
88
|
+
// `s` styles the current blocks. `quoted` is the muted context for blockquotes; inside a
|
|
89
|
+
// quote it is unset, so nested quotes reuse the current (already muted) context.
|
|
90
|
+
// `tasks` maps each task item to its `taskItems` ordinal when checkboxes are pressable.
|
|
91
|
+
interface Context {
|
|
92
|
+
s: Styles;
|
|
93
|
+
quoted?: Context;
|
|
94
|
+
tasks?: TaskToggles;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
interface TaskToggles {
|
|
98
|
+
ordinals: Map<MarkdownListItem, number>;
|
|
99
|
+
onToggle(ordinal: number): void;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const CHECKBOX_HIT_SLOP = 8;
|
|
103
|
+
|
|
104
|
+
function Link(props: { url: string; style: StyleProp<TextStyle>; children: ReactNode }) {
|
|
105
|
+
const { url } = props;
|
|
106
|
+
const open = useCallback(() => void openExternalUrl(url), [url]);
|
|
107
|
+
return (
|
|
108
|
+
<Text accessibilityRole="link" onPress={open} style={props.style}>
|
|
109
|
+
{props.children}
|
|
110
|
+
</Text>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function renderInline(nodes: MarkdownInline[], s: Styles): ReactNode[] {
|
|
115
|
+
return nodes.map((node, index) => {
|
|
116
|
+
switch (node.type) {
|
|
117
|
+
case "text":
|
|
118
|
+
return node.text;
|
|
119
|
+
case "break":
|
|
120
|
+
return "\n";
|
|
121
|
+
case "code":
|
|
122
|
+
return (
|
|
123
|
+
<Text key={index} style={s.code}>
|
|
124
|
+
{node.text}
|
|
125
|
+
</Text>
|
|
126
|
+
);
|
|
127
|
+
case "link":
|
|
128
|
+
return (
|
|
129
|
+
<Link key={index} url={node.url} style={s.link}>
|
|
130
|
+
{renderInline(node.children, s)}
|
|
131
|
+
</Link>
|
|
132
|
+
);
|
|
133
|
+
default:
|
|
134
|
+
return (
|
|
135
|
+
<Text key={index} style={s[node.type]}>
|
|
136
|
+
{renderInline(node.children, s)}
|
|
137
|
+
</Text>
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function renderBlocks(blocks: MarkdownBlock[], ctx: Context): ReactNode[] {
|
|
144
|
+
return blocks.map((block, index) => <Block key={index} block={block} ctx={ctx} />);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function TaskBox(props: { checked: boolean; ordinal: number; tasks: TaskToggles; s: Styles }) {
|
|
148
|
+
const { checked, ordinal, tasks } = props;
|
|
149
|
+
const { onToggle } = tasks;
|
|
150
|
+
const toggle = useCallback(() => onToggle(ordinal), [onToggle, ordinal]);
|
|
151
|
+
const a11yState = useMemo(() => ({ checked }), [checked]);
|
|
152
|
+
return (
|
|
153
|
+
<Pressable
|
|
154
|
+
accessibilityRole="checkbox"
|
|
155
|
+
accessibilityState={a11yState}
|
|
156
|
+
accessibilityLabel={checked ? "Uncheck item" : "Check item"}
|
|
157
|
+
hitSlop={CHECKBOX_HIT_SLOP}
|
|
158
|
+
onPress={toggle}
|
|
159
|
+
>
|
|
160
|
+
<Icon name={checked ? "SquareCheck" : "Square"} size={14} color={props.s.ink} />
|
|
161
|
+
</Pressable>
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function ListMarker(props: {
|
|
166
|
+
list: ListBlock;
|
|
167
|
+
item: MarkdownListItem;
|
|
168
|
+
index: number;
|
|
169
|
+
ctx: Context;
|
|
170
|
+
}) {
|
|
171
|
+
const { list, item, index, ctx } = props;
|
|
172
|
+
const { s, tasks } = ctx;
|
|
173
|
+
const { checked } = item;
|
|
174
|
+
const ordinal = tasks?.ordinals.get(item);
|
|
175
|
+
if (checked !== null && tasks && ordinal !== undefined) {
|
|
176
|
+
return <TaskBox checked={checked} ordinal={ordinal} tasks={tasks} s={s} />;
|
|
177
|
+
}
|
|
178
|
+
if (checked !== null) {
|
|
179
|
+
return <Icon name={checked ? "SquareCheck" : "Square"} size={14} color={s.ink} />;
|
|
180
|
+
}
|
|
181
|
+
return (
|
|
182
|
+
<Text selectable style={s.text}>
|
|
183
|
+
{list.ordered ? `${list.start + index}.` : "•"}
|
|
184
|
+
</Text>
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function List({ list, ctx }: { list: ListBlock; ctx: Context }) {
|
|
189
|
+
const { s } = ctx;
|
|
190
|
+
return (
|
|
191
|
+
<View style={s.list}>
|
|
192
|
+
{list.items.map((item, index) => (
|
|
193
|
+
<View key={index} style={s.item}>
|
|
194
|
+
<View style={s.marker}>
|
|
195
|
+
<ListMarker list={list} item={item} index={index} ctx={ctx} />
|
|
196
|
+
</View>
|
|
197
|
+
<View style={s.itemBody}>{renderBlocks(item.children, ctx)}</View>
|
|
198
|
+
</View>
|
|
199
|
+
))}
|
|
200
|
+
</View>
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Lays the table out column by column so every column sizes to its widest cell.
|
|
205
|
+
// Cells never wrap inside the horizontal ScrollView, so rows stay aligned.
|
|
206
|
+
function Table({ table, s }: { table: TableBlock; s: Styles }) {
|
|
207
|
+
const columns = useMemo(
|
|
208
|
+
() =>
|
|
209
|
+
table.header.map((head, c) => {
|
|
210
|
+
const textAlign = table.align[c] ?? "left";
|
|
211
|
+
return {
|
|
212
|
+
cells: [head, ...table.rows.map((row) => row[c] ?? [])],
|
|
213
|
+
head: { ...s.headText, textAlign },
|
|
214
|
+
body: { ...s.text, textAlign },
|
|
215
|
+
};
|
|
216
|
+
}),
|
|
217
|
+
[table, s],
|
|
218
|
+
);
|
|
219
|
+
return (
|
|
220
|
+
<ScrollView horizontal>
|
|
221
|
+
<View style={s.table}>
|
|
222
|
+
{columns.map((column, c) => (
|
|
223
|
+
<View key={c} style={c === 0 ? s.column : s.columnDivided}>
|
|
224
|
+
{column.cells.map((cell, r) => (
|
|
225
|
+
<View key={r} style={r === 0 ? s.headCell : s.cell}>
|
|
226
|
+
<Text selectable style={r === 0 ? column.head : column.body}>
|
|
227
|
+
{renderInline(cell, s)}
|
|
228
|
+
</Text>
|
|
229
|
+
</View>
|
|
230
|
+
))}
|
|
231
|
+
</View>
|
|
232
|
+
))}
|
|
233
|
+
</View>
|
|
234
|
+
</ScrollView>
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function Block({ block, ctx }: { block: MarkdownBlock; ctx: Context }) {
|
|
239
|
+
const { s } = ctx;
|
|
240
|
+
switch (block.type) {
|
|
241
|
+
case "heading":
|
|
242
|
+
return (
|
|
243
|
+
<Text selectable accessibilityRole="header" style={s.headings[block.level - 1]}>
|
|
244
|
+
{renderInline(block.children, s)}
|
|
245
|
+
</Text>
|
|
246
|
+
);
|
|
247
|
+
case "paragraph":
|
|
248
|
+
return (
|
|
249
|
+
<Text selectable style={s.text}>
|
|
250
|
+
{renderInline(block.children, s)}
|
|
251
|
+
</Text>
|
|
252
|
+
);
|
|
253
|
+
case "code":
|
|
254
|
+
return (
|
|
255
|
+
<View style={s.codeBlock}>
|
|
256
|
+
<ScrollView horizontal contentContainerStyle={s.codeContent}>
|
|
257
|
+
<Text selectable style={s.codeText}>
|
|
258
|
+
{block.text}
|
|
259
|
+
</Text>
|
|
260
|
+
</ScrollView>
|
|
261
|
+
</View>
|
|
262
|
+
);
|
|
263
|
+
case "blockquote":
|
|
264
|
+
return (
|
|
265
|
+
<View style={s.quote}>
|
|
266
|
+
{renderBlocks(block.children, ctx.quoted ? { ...ctx.quoted, tasks: ctx.tasks } : ctx)}
|
|
267
|
+
</View>
|
|
268
|
+
);
|
|
269
|
+
case "list":
|
|
270
|
+
return <List list={block} ctx={ctx} />;
|
|
271
|
+
case "table":
|
|
272
|
+
return <Table table={block} s={s} />;
|
|
273
|
+
case "rule":
|
|
274
|
+
return <View style={s.rule} />;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export interface MarkdownProps {
|
|
279
|
+
theme: Theme;
|
|
280
|
+
source: string;
|
|
281
|
+
compact?: boolean;
|
|
282
|
+
muted?: boolean;
|
|
283
|
+
/** Makes task checkboxes pressable. Gets the item's ordinal in `taskItems` order. */
|
|
284
|
+
onToggleTask?(ordinal: number): void;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export function Markdown(props: MarkdownProps) {
|
|
288
|
+
const { colors } = props.theme;
|
|
289
|
+
const compact = props.compact === true;
|
|
290
|
+
const muted = props.muted === true;
|
|
291
|
+
const { onToggleTask } = props;
|
|
292
|
+
const blocks = useMemo(() => parseMarkdown(props.source), [props.source]);
|
|
293
|
+
const ctx = useMemo<Context>(() => {
|
|
294
|
+
const quoted: Context = { s: createStyles(colors, compact, true) };
|
|
295
|
+
const tasks = onToggleTask
|
|
296
|
+
? { ordinals: new Map(taskItems(blocks).map((item, n) => [item, n])), onToggle: onToggleTask }
|
|
297
|
+
: undefined;
|
|
298
|
+
return { s: muted ? quoted.s : createStyles(colors, compact, false), quoted, tasks };
|
|
299
|
+
}, [colors, compact, muted, blocks, onToggleTask]);
|
|
300
|
+
if (blocks.length === 0) return null;
|
|
301
|
+
return <View style={ctx.s.root}>{renderBlocks(blocks, ctx)}</View>;
|
|
302
|
+
}
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import { Icon, Modal, TextInput } from "@getpaseo/plugin/client/react-native";
|
|
2
|
+
import { type ReactNode, useCallback, useMemo, useState } from "react";
|
|
3
|
+
import { Platform, Pressable, type PressableStateCallbackType, Text, View } from "react-native";
|
|
4
|
+
import { type AgentChoice, type ModelChoice, searchModels } from "./agent-options";
|
|
5
|
+
import { ProviderIcon } from "./provider-icon";
|
|
6
|
+
import type { Theme } from "./ui";
|
|
7
|
+
|
|
8
|
+
export interface ModelBrowserProps {
|
|
9
|
+
theme: Theme;
|
|
10
|
+
agents: readonly AgentChoice[];
|
|
11
|
+
agentId: string | null;
|
|
12
|
+
modelId: string | null;
|
|
13
|
+
onClose(): void;
|
|
14
|
+
onSelect(agentId: string, modelId: string): void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const CONTENT_STYLE = { padding: 0, gap: 0 } as const;
|
|
18
|
+
|
|
19
|
+
function modelCount(agent: AgentChoice): string {
|
|
20
|
+
return agent.models.length === 1 ? "1 model" : `${agent.models.length} models`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function useBrowserStyles(theme: Theme) {
|
|
24
|
+
const { colors } = theme;
|
|
25
|
+
return useMemo(() => {
|
|
26
|
+
const row = {
|
|
27
|
+
minHeight: 36,
|
|
28
|
+
paddingVertical: 8,
|
|
29
|
+
paddingHorizontal: 12,
|
|
30
|
+
flexDirection: "row" as const,
|
|
31
|
+
alignItems: "center" as const,
|
|
32
|
+
gap: 8,
|
|
33
|
+
};
|
|
34
|
+
return {
|
|
35
|
+
row,
|
|
36
|
+
rowHovered: { ...row, backgroundColor: colors.surface1 },
|
|
37
|
+
rowPressed: { ...row, backgroundColor: colors.surface2 },
|
|
38
|
+
separator: { height: 1, backgroundColor: colors.border },
|
|
39
|
+
slot: { width: 16, alignItems: "center" as const },
|
|
40
|
+
search: {
|
|
41
|
+
flexDirection: "row" as const,
|
|
42
|
+
alignItems: "center" as const,
|
|
43
|
+
gap: 8,
|
|
44
|
+
paddingHorizontal: 12,
|
|
45
|
+
borderBottomWidth: 1,
|
|
46
|
+
borderBottomColor: colors.border,
|
|
47
|
+
},
|
|
48
|
+
// Web only. Paseo's own search fields hide the focus outline the same way.
|
|
49
|
+
input: {
|
|
50
|
+
flex: 1,
|
|
51
|
+
paddingVertical: 8,
|
|
52
|
+
fontSize: 14,
|
|
53
|
+
color: colors.foreground,
|
|
54
|
+
...(Platform.OS === "web" ? ({ outlineStyle: "none" } as object) : {}),
|
|
55
|
+
},
|
|
56
|
+
section: {
|
|
57
|
+
paddingHorizontal: 12,
|
|
58
|
+
paddingTop: 8,
|
|
59
|
+
paddingBottom: 4,
|
|
60
|
+
fontSize: 12,
|
|
61
|
+
color: colors.foregroundMuted,
|
|
62
|
+
},
|
|
63
|
+
header: {
|
|
64
|
+
flexDirection: "row" as const,
|
|
65
|
+
alignItems: "center" as const,
|
|
66
|
+
gap: 8,
|
|
67
|
+
paddingHorizontal: 12,
|
|
68
|
+
paddingVertical: 8,
|
|
69
|
+
},
|
|
70
|
+
title: { flex: 1, fontSize: 14, fontWeight: "500" as const, color: colors.foreground },
|
|
71
|
+
label: { fontSize: 14, color: colors.foreground, flexShrink: 0 },
|
|
72
|
+
providerLabel: { flex: 1, fontSize: 14, color: colors.foreground },
|
|
73
|
+
muted: { fontSize: 12, color: colors.foregroundMuted },
|
|
74
|
+
description: { flex: 1, minWidth: 0, fontSize: 12, color: colors.foregroundMuted },
|
|
75
|
+
text: {
|
|
76
|
+
flex: 1,
|
|
77
|
+
minWidth: 0,
|
|
78
|
+
flexDirection: "row" as const,
|
|
79
|
+
alignItems: "baseline" as const,
|
|
80
|
+
gap: 8,
|
|
81
|
+
},
|
|
82
|
+
empty: { alignItems: "center" as const, gap: 8, padding: 24 },
|
|
83
|
+
};
|
|
84
|
+
}, [colors]);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
type BrowserStyles = ReturnType<typeof useBrowserStyles>;
|
|
88
|
+
|
|
89
|
+
function useRowStyle(styles: BrowserStyles) {
|
|
90
|
+
return useCallback(
|
|
91
|
+
(state: PressableStateCallbackType) =>
|
|
92
|
+
state.pressed
|
|
93
|
+
? styles.rowPressed
|
|
94
|
+
: (state as { hovered?: boolean }).hovered
|
|
95
|
+
? styles.rowHovered
|
|
96
|
+
: styles.row,
|
|
97
|
+
[styles],
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function ModelRow(props: {
|
|
102
|
+
theme: Theme;
|
|
103
|
+
styles: BrowserStyles;
|
|
104
|
+
agent: AgentChoice;
|
|
105
|
+
model: ModelChoice;
|
|
106
|
+
description: string;
|
|
107
|
+
selected: boolean;
|
|
108
|
+
onSelect(agentId: string, modelId: string): void;
|
|
109
|
+
}) {
|
|
110
|
+
const { theme, styles, agent, model, onSelect } = props;
|
|
111
|
+
const muted = theme.colors.foregroundMuted;
|
|
112
|
+
const press = useCallback(() => onSelect(agent.id, model.id), [onSelect, agent.id, model.id]);
|
|
113
|
+
const rowStyle = useRowStyle(styles);
|
|
114
|
+
return (
|
|
115
|
+
<Pressable
|
|
116
|
+
accessibilityRole="button"
|
|
117
|
+
accessibilityLabel={`${agent.label} ${model.label}`}
|
|
118
|
+
accessibilityState={{ selected: props.selected }}
|
|
119
|
+
onPress={press}
|
|
120
|
+
style={rowStyle}
|
|
121
|
+
>
|
|
122
|
+
<View style={styles.slot}>
|
|
123
|
+
<ProviderIcon provider={agent.id} iconSvg={agent.iconSvg} size={14} color={muted} />
|
|
124
|
+
</View>
|
|
125
|
+
<View style={styles.text}>
|
|
126
|
+
<Text numberOfLines={1} style={styles.label}>
|
|
127
|
+
{model.label}
|
|
128
|
+
</Text>
|
|
129
|
+
<Text numberOfLines={1} style={styles.description}>
|
|
130
|
+
{props.description}
|
|
131
|
+
</Text>
|
|
132
|
+
</View>
|
|
133
|
+
<View style={styles.slot}>
|
|
134
|
+
{props.selected ? <Icon name="Check" size={14} color={muted} /> : null}
|
|
135
|
+
</View>
|
|
136
|
+
</Pressable>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function ProviderRow(props: {
|
|
141
|
+
theme: Theme;
|
|
142
|
+
styles: BrowserStyles;
|
|
143
|
+
agent: AgentChoice;
|
|
144
|
+
onOpen(agentId: string): void;
|
|
145
|
+
}) {
|
|
146
|
+
const { theme, styles, agent, onOpen } = props;
|
|
147
|
+
const muted = theme.colors.foregroundMuted;
|
|
148
|
+
const press = useCallback(() => onOpen(agent.id), [onOpen, agent.id]);
|
|
149
|
+
const rowStyle = useRowStyle(styles);
|
|
150
|
+
return (
|
|
151
|
+
<Pressable
|
|
152
|
+
accessibilityRole="button"
|
|
153
|
+
accessibilityLabel={`${agent.label}, ${modelCount(agent)}`}
|
|
154
|
+
onPress={press}
|
|
155
|
+
style={rowStyle}
|
|
156
|
+
>
|
|
157
|
+
<View style={styles.slot}>
|
|
158
|
+
<ProviderIcon provider={agent.id} iconSvg={agent.iconSvg} size={14} color={muted} />
|
|
159
|
+
</View>
|
|
160
|
+
<Text numberOfLines={1} style={styles.providerLabel}>
|
|
161
|
+
{agent.label}
|
|
162
|
+
</Text>
|
|
163
|
+
<Text style={styles.muted}>{modelCount(agent)}</Text>
|
|
164
|
+
<Icon name="ChevronRight" size={14} color={muted} />
|
|
165
|
+
</Pressable>
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function rowsWithSeparators<T>(
|
|
170
|
+
items: readonly T[],
|
|
171
|
+
styles: BrowserStyles,
|
|
172
|
+
row: (item: T) => {
|
|
173
|
+
key: string;
|
|
174
|
+
node: ReactNode;
|
|
175
|
+
},
|
|
176
|
+
) {
|
|
177
|
+
return items.map((item, index) => {
|
|
178
|
+
const { key, node } = row(item);
|
|
179
|
+
return (
|
|
180
|
+
<View key={key}>
|
|
181
|
+
{index > 0 ? <View style={styles.separator} /> : null}
|
|
182
|
+
{node}
|
|
183
|
+
</View>
|
|
184
|
+
);
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Paseo's model picker: providers first, then the models of one provider. Search covers all models.
|
|
189
|
+
export function ModelBrowser(props: ModelBrowserProps) {
|
|
190
|
+
const { theme, agents, onSelect, onClose } = props;
|
|
191
|
+
const { colors } = theme;
|
|
192
|
+
const styles = useBrowserStyles(theme);
|
|
193
|
+
const [openAgentId, setOpenAgentId] = useState<string | null>(
|
|
194
|
+
agents.length === 1 ? (agents[0]?.id ?? null) : null,
|
|
195
|
+
);
|
|
196
|
+
const [query, setQuery] = useState("");
|
|
197
|
+
const openAgent = agents.find((agent) => agent.id === openAgentId) ?? null;
|
|
198
|
+
const showProvider = useCallback((agentId: string) => {
|
|
199
|
+
setOpenAgentId(agentId);
|
|
200
|
+
setQuery("");
|
|
201
|
+
}, []);
|
|
202
|
+
const back = useCallback(() => {
|
|
203
|
+
setOpenAgentId(null);
|
|
204
|
+
setQuery("");
|
|
205
|
+
}, []);
|
|
206
|
+
const openChange = useCallback((open: boolean) => (open ? undefined : onClose()), [onClose]);
|
|
207
|
+
const matches = useMemo(() => {
|
|
208
|
+
if (openAgent) {
|
|
209
|
+
return query.trim()
|
|
210
|
+
? searchModels([openAgent], query)
|
|
211
|
+
: openAgent.models.map((model) => ({ agent: openAgent, model }));
|
|
212
|
+
}
|
|
213
|
+
return searchModels(agents, query);
|
|
214
|
+
}, [agents, openAgent, query]);
|
|
215
|
+
const isSelected = (agent: AgentChoice, model: ModelChoice) =>
|
|
216
|
+
agent.id === props.agentId && model.id === props.modelId;
|
|
217
|
+
|
|
218
|
+
const modelRows = matches.map(({ agent, model }) => (
|
|
219
|
+
<ModelRow
|
|
220
|
+
key={`${agent.id}/${model.id}`}
|
|
221
|
+
theme={theme}
|
|
222
|
+
styles={styles}
|
|
223
|
+
agent={agent}
|
|
224
|
+
model={model}
|
|
225
|
+
description={
|
|
226
|
+
openAgent
|
|
227
|
+
? (model.description ?? model.id)
|
|
228
|
+
: `${agent.label} · ${model.description ?? model.id}`
|
|
229
|
+
}
|
|
230
|
+
selected={isSelected(agent, model)}
|
|
231
|
+
onSelect={onSelect}
|
|
232
|
+
/>
|
|
233
|
+
));
|
|
234
|
+
const listingProviders = !openAgent && !query.trim();
|
|
235
|
+
return (
|
|
236
|
+
<Modal title="Select model" open onOpenChange={openChange}>
|
|
237
|
+
<Modal.Content contentContainerStyle={CONTENT_STYLE}>
|
|
238
|
+
{openAgent ? (
|
|
239
|
+
<View style={styles.header}>
|
|
240
|
+
{agents.length > 1 ? (
|
|
241
|
+
<Pressable
|
|
242
|
+
accessibilityRole="button"
|
|
243
|
+
accessibilityLabel="All providers"
|
|
244
|
+
hitSlop={8}
|
|
245
|
+
onPress={back}
|
|
246
|
+
>
|
|
247
|
+
<Icon name="ArrowLeft" size={16} color={colors.foregroundMuted} />
|
|
248
|
+
</Pressable>
|
|
249
|
+
) : null}
|
|
250
|
+
<ProviderIcon
|
|
251
|
+
provider={openAgent.id}
|
|
252
|
+
iconSvg={openAgent.iconSvg}
|
|
253
|
+
size={16}
|
|
254
|
+
color={colors.foreground}
|
|
255
|
+
/>
|
|
256
|
+
<Text numberOfLines={1} style={styles.title}>
|
|
257
|
+
{openAgent.label}
|
|
258
|
+
</Text>
|
|
259
|
+
</View>
|
|
260
|
+
) : null}
|
|
261
|
+
<View style={styles.search}>
|
|
262
|
+
<Icon name="Search" size={14} color={colors.foregroundMuted} />
|
|
263
|
+
<TextInput
|
|
264
|
+
value={query}
|
|
265
|
+
onChangeText={setQuery}
|
|
266
|
+
placeholder={openAgent ? "Search models..." : "Search all models..."}
|
|
267
|
+
placeholderTextColor={colors.foregroundMuted}
|
|
268
|
+
autoCapitalize="none"
|
|
269
|
+
autoFocus
|
|
270
|
+
accessibilityLabel={openAgent ? "Search models" : "Search all models"}
|
|
271
|
+
style={styles.input}
|
|
272
|
+
/>
|
|
273
|
+
</View>
|
|
274
|
+
{listingProviders ? (
|
|
275
|
+
<>
|
|
276
|
+
<Text style={styles.section}>Providers</Text>
|
|
277
|
+
{rowsWithSeparators(agents, styles, (agent) => ({
|
|
278
|
+
key: agent.id,
|
|
279
|
+
node: (
|
|
280
|
+
<ProviderRow theme={theme} styles={styles} agent={agent} onOpen={showProvider} />
|
|
281
|
+
),
|
|
282
|
+
}))}
|
|
283
|
+
</>
|
|
284
|
+
) : (
|
|
285
|
+
modelRows
|
|
286
|
+
)}
|
|
287
|
+
{!listingProviders && matches.length === 0 ? (
|
|
288
|
+
<View style={styles.empty}>
|
|
289
|
+
<Icon name="Search" size={16} color={colors.foregroundMuted} />
|
|
290
|
+
<Text style={styles.muted}>{`No models match "${query.trim()}"`}</Text>
|
|
291
|
+
</View>
|
|
292
|
+
) : null}
|
|
293
|
+
</Modal.Content>
|
|
294
|
+
</Modal>
|
|
295
|
+
);
|
|
296
|
+
}
|