@terreno/ui 0.2.0 → 0.3.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.
@@ -0,0 +1,551 @@
1
+ import React, {useCallback, useRef, useState} from "react";
2
+ import {Image as RNImage, type ScrollView as RNScrollView} from "react-native";
3
+
4
+ import {AttachmentPreview} from "./AttachmentPreview";
5
+ import {Box} from "./Box";
6
+ import {Button} from "./Button";
7
+ import type {SelectedFile} from "./FilePickerButton";
8
+ import {FilePickerButton} from "./FilePickerButton";
9
+ import {Heading} from "./Heading";
10
+ import {Icon} from "./Icon";
11
+ import {IconButton} from "./IconButton";
12
+ import {MarkdownView} from "./MarkdownView";
13
+ import {Modal} from "./Modal";
14
+ import {Spinner} from "./Spinner";
15
+ import {Text} from "./Text";
16
+ import {TextArea} from "./TextArea";
17
+ import {TextField} from "./TextField";
18
+
19
+ // ============================================================
20
+ // Content Part Types (mirroring backend types for rendering)
21
+ // ============================================================
22
+
23
+ export interface TextContentPart {
24
+ type: "text";
25
+ text: string;
26
+ }
27
+
28
+ export interface ImageContentPart {
29
+ type: "image";
30
+ url: string;
31
+ mimeType?: string;
32
+ }
33
+
34
+ export interface FileContentPart {
35
+ type: "file";
36
+ url: string;
37
+ filename?: string;
38
+ mimeType: string;
39
+ }
40
+
41
+ export type MessageContentPart = TextContentPart | ImageContentPart | FileContentPart;
42
+
43
+ // ============================================================
44
+ // Tool Call Types
45
+ // ============================================================
46
+
47
+ export interface ToolCallInfo {
48
+ args: Record<string, unknown>;
49
+ toolCallId: string;
50
+ toolName: string;
51
+ }
52
+
53
+ export interface ToolResultInfo {
54
+ result: unknown;
55
+ toolCallId: string;
56
+ toolName: string;
57
+ }
58
+
59
+ // ============================================================
60
+ // Message Types
61
+ // ============================================================
62
+
63
+ export interface GPTChatMessage {
64
+ content: string;
65
+ contentParts?: MessageContentPart[];
66
+ role: "user" | "assistant" | "system" | "tool-call" | "tool-result";
67
+ toolCall?: ToolCallInfo;
68
+ toolResult?: ToolResultInfo;
69
+ }
70
+
71
+ export interface GPTChatHistory {
72
+ id: string;
73
+ prompts: GPTChatMessage[];
74
+ title?: string;
75
+ updated?: string;
76
+ }
77
+
78
+ export interface MCPServerStatus {
79
+ connected: boolean;
80
+ name: string;
81
+ }
82
+
83
+ export interface GPTChatProps {
84
+ attachments?: SelectedFile[];
85
+ currentHistoryId?: string;
86
+ currentMessages: GPTChatMessage[];
87
+ geminiApiKey?: string;
88
+ histories: GPTChatHistory[];
89
+ isStreaming?: boolean;
90
+ mcpServers?: MCPServerStatus[];
91
+ onAttachFiles?: (files: SelectedFile[]) => void;
92
+ onCreateHistory: () => void;
93
+ onDeleteHistory: (id: string) => void;
94
+ onGeminiApiKeyChange?: (key: string) => void;
95
+ onMemoryEdit?: (memory: string) => void;
96
+ onRemoveAttachment?: (index: number) => void;
97
+ onSelectHistory: (id: string) => void;
98
+ onSubmit: (prompt: string) => void;
99
+ onUpdateTitle?: (id: string, title: string) => void;
100
+ systemMemory?: string;
101
+ testID?: string;
102
+ }
103
+
104
+ const ToolCallCard = ({toolCall}: {toolCall: ToolCallInfo}): React.ReactElement => {
105
+ const [isExpanded, setIsExpanded] = useState(false);
106
+
107
+ return (
108
+ <Box border="default" padding={2} rounding="md">
109
+ <Box
110
+ accessibilityHint="Toggle tool call details"
111
+ accessibilityLabel={`Tool: ${toolCall.toolName}`}
112
+ alignItems="center"
113
+ direction="row"
114
+ gap={1}
115
+ onClick={() => setIsExpanded(!isExpanded)}
116
+ >
117
+ <Icon iconName="wrench" size="xs" />
118
+ <Text bold size="sm">
119
+ Tool: {toolCall.toolName}
120
+ </Text>
121
+ <Icon iconName={isExpanded ? "chevron-up" : "chevron-down"} size="xs" />
122
+ </Box>
123
+ {isExpanded ? (
124
+ <Box marginTop={1} padding={1}>
125
+ <Text color="secondaryDark" size="sm">
126
+ {JSON.stringify(toolCall.args, null, 2)}
127
+ </Text>
128
+ </Box>
129
+ ) : null}
130
+ </Box>
131
+ );
132
+ };
133
+
134
+ const ToolResultCard = ({toolResult}: {toolResult: ToolResultInfo}): React.ReactElement => {
135
+ const [isExpanded, setIsExpanded] = useState(false);
136
+
137
+ return (
138
+ <Box border="default" padding={2} rounding="md">
139
+ <Box
140
+ accessibilityHint="Toggle tool result details"
141
+ accessibilityLabel={`Result: ${toolResult.toolName}`}
142
+ alignItems="center"
143
+ direction="row"
144
+ gap={1}
145
+ onClick={() => setIsExpanded(!isExpanded)}
146
+ >
147
+ <Icon iconName="check" size="xs" />
148
+ <Text bold size="sm">
149
+ Result: {toolResult.toolName}
150
+ </Text>
151
+ <Icon iconName={isExpanded ? "chevron-up" : "chevron-down"} size="xs" />
152
+ </Box>
153
+ {isExpanded ? (
154
+ <Box marginTop={1} padding={1}>
155
+ <Text color="secondaryDark" size="sm">
156
+ {typeof toolResult.result === "string"
157
+ ? toolResult.result
158
+ : JSON.stringify(toolResult.result, null, 2)}
159
+ </Text>
160
+ </Box>
161
+ ) : null}
162
+ </Box>
163
+ );
164
+ };
165
+
166
+ const handleDownloadFile = (url: string, filename: string): void => {
167
+ if (typeof window === "undefined") {
168
+ return;
169
+ }
170
+ const link = document.createElement("a");
171
+ link.href = url;
172
+ link.download = filename;
173
+ document.body.appendChild(link);
174
+ link.click();
175
+ document.body.removeChild(link);
176
+ };
177
+
178
+ const MessageContentParts = ({parts}: {parts: MessageContentPart[]}): React.ReactElement => {
179
+ return (
180
+ <Box gap={2}>
181
+ {parts.map((part, index) => {
182
+ if (part.type === "image") {
183
+ return (
184
+ <RNImage
185
+ key={`content-${index}`}
186
+ resizeMode="contain"
187
+ source={{uri: part.url}}
188
+ style={{borderRadius: 8, height: 400, maxWidth: 800, minWidth: 400, width: "100%"}}
189
+ />
190
+ );
191
+ }
192
+ if (part.type === "file") {
193
+ const hasDownloadableUrl = part.url?.startsWith("data:") || part.url?.startsWith("http");
194
+ const filename = part.filename ?? "File";
195
+ const isPdf = part.mimeType === "application/pdf";
196
+ const iconName = isPdf ? "file-pdf" : "file";
197
+
198
+ if (hasDownloadableUrl) {
199
+ return (
200
+ <Box
201
+ accessibilityHint="Download this file"
202
+ accessibilityLabel={`File: ${filename}`}
203
+ alignItems="center"
204
+ border="default"
205
+ direction="row"
206
+ gap={1}
207
+ key={`content-${index}`}
208
+ onClick={() => handleDownloadFile(part.url, filename)}
209
+ padding={2}
210
+ rounding="md"
211
+ >
212
+ <Icon iconName={iconName} size="sm" />
213
+ <Text size="sm">{filename}</Text>
214
+ <Icon iconName="download" size="xs" />
215
+ </Box>
216
+ );
217
+ }
218
+
219
+ return (
220
+ <Box
221
+ alignItems="center"
222
+ border="default"
223
+ direction="row"
224
+ gap={1}
225
+ key={`content-${index}`}
226
+ padding={2}
227
+ rounding="md"
228
+ >
229
+ <Icon iconName={iconName} size="sm" />
230
+ <Text size="sm">{filename}</Text>
231
+ </Box>
232
+ );
233
+ }
234
+ return null;
235
+ })}
236
+ </Box>
237
+ );
238
+ };
239
+
240
+ const MCPStatusIndicator = ({servers}: {servers: MCPServerStatus[]}): React.ReactElement => {
241
+ const [showList, setShowList] = useState(false);
242
+ const connectedCount = servers.filter((s) => s.connected).length;
243
+
244
+ return (
245
+ <Box>
246
+ <Box
247
+ accessibilityHint="Show MCP server list"
248
+ accessibilityLabel="MCP server status"
249
+ alignItems="center"
250
+ direction="row"
251
+ gap={1}
252
+ onClick={() => setShowList(!showList)}
253
+ >
254
+ <Box
255
+ color={connectedCount > 0 ? "success" : "error"}
256
+ height={8}
257
+ rounding="circle"
258
+ width={8}
259
+ />
260
+ <Text color="secondaryDark" size="sm">
261
+ {connectedCount}/{servers.length} MCP
262
+ </Text>
263
+ </Box>
264
+ {showList ? (
265
+ <Box border="default" marginTop={1} padding={2} position="absolute" rounding="md">
266
+ {servers.map((server) => (
267
+ <Box alignItems="center" direction="row" gap={1} key={server.name} padding={1}>
268
+ <Box
269
+ color={server.connected ? "success" : "error"}
270
+ height={6}
271
+ rounding="circle"
272
+ width={6}
273
+ />
274
+ <Text size="sm">{server.name}</Text>
275
+ </Box>
276
+ ))}
277
+ </Box>
278
+ ) : null}
279
+ </Box>
280
+ );
281
+ };
282
+
283
+ export const GPTChat = ({
284
+ attachments = [],
285
+ currentHistoryId,
286
+ currentMessages,
287
+ geminiApiKey,
288
+ histories,
289
+ isStreaming = false,
290
+ mcpServers,
291
+ onAttachFiles,
292
+ onCreateHistory,
293
+ onDeleteHistory,
294
+ onGeminiApiKeyChange,
295
+ onMemoryEdit,
296
+ onRemoveAttachment,
297
+ onSelectHistory,
298
+ onSubmit,
299
+ systemMemory,
300
+ testID,
301
+ }: GPTChatProps): React.ReactElement => {
302
+ const [inputValue, setInputValue] = useState("");
303
+ const scrollViewRef = useRef<RNScrollView>(null);
304
+ const [isScrolledUp, setIsScrolledUp] = useState(false);
305
+ const [isApiKeyModalVisible, setIsApiKeyModalVisible] = useState(false);
306
+ const [apiKeyDraft, setApiKeyDraft] = useState(geminiApiKey ?? "");
307
+
308
+ const handleSubmit = useCallback(() => {
309
+ const trimmed = inputValue.trim();
310
+ if (!trimmed || isStreaming) {
311
+ return;
312
+ }
313
+ onSubmit(trimmed);
314
+ setInputValue("");
315
+ }, [inputValue, isStreaming, onSubmit]);
316
+
317
+ const handleCopyMessage = useCallback(async (text: string) => {
318
+ const Clipboard = await import("expo-clipboard");
319
+ await Clipboard.setStringAsync(text);
320
+ }, []);
321
+
322
+ const scrollToBottom = useCallback(() => {
323
+ scrollViewRef.current?.scrollToEnd({animated: true});
324
+ setIsScrolledUp(false);
325
+ }, []);
326
+
327
+ const handleFilesSelected = useCallback(
328
+ (files: SelectedFile[]) => {
329
+ onAttachFiles?.(files);
330
+ },
331
+ [onAttachFiles]
332
+ );
333
+
334
+ const handleOpenApiKeyModal = useCallback(() => {
335
+ setApiKeyDraft(geminiApiKey ?? "");
336
+ setIsApiKeyModalVisible(true);
337
+ }, [geminiApiKey]);
338
+
339
+ const handleSaveApiKey = useCallback(() => {
340
+ onGeminiApiKeyChange?.(apiKeyDraft);
341
+ setIsApiKeyModalVisible(false);
342
+ }, [apiKeyDraft, onGeminiApiKeyChange]);
343
+
344
+ return (
345
+ <Box direction="row" flex="grow" testID={testID}>
346
+ {/* Sidebar */}
347
+ <Box border="default" color="base" minWidth={250} overflow="scrollY" padding={3} width="30%">
348
+ <Box alignItems="center" direction="row" justifyContent="between" marginBottom={3}>
349
+ <Heading size="sm">Chats</Heading>
350
+ <Box direction="row" gap={1}>
351
+ {mcpServers && mcpServers.length > 0 ? (
352
+ <MCPStatusIndicator servers={mcpServers} />
353
+ ) : null}
354
+ {onGeminiApiKeyChange ? (
355
+ <IconButton
356
+ accessibilityLabel="Set Gemini API key"
357
+ iconName="key"
358
+ onClick={handleOpenApiKeyModal}
359
+ testID="gpt-api-key-button"
360
+ />
361
+ ) : null}
362
+ {onMemoryEdit ? (
363
+ <IconButton
364
+ accessibilityLabel="Edit system memory"
365
+ iconName="gear"
366
+ onClick={() => onMemoryEdit(systemMemory ?? "")}
367
+ testID="gpt-memory-button"
368
+ />
369
+ ) : null}
370
+ <IconButton
371
+ accessibilityLabel="New chat"
372
+ iconName="plus"
373
+ onClick={onCreateHistory}
374
+ testID="gpt-new-chat-button"
375
+ />
376
+ </Box>
377
+ </Box>
378
+
379
+ {histories.map((history) => (
380
+ <Box
381
+ accessibilityHint="Opens this chat history"
382
+ accessibilityLabel={`Select chat: ${history.title ?? "New Chat"}`}
383
+ alignItems="center"
384
+ color={history.id === currentHistoryId ? "primary" : undefined}
385
+ direction="row"
386
+ justifyContent="between"
387
+ key={history.id}
388
+ marginBottom={1}
389
+ onClick={() => onSelectHistory(history.id)}
390
+ padding={2}
391
+ rounding="md"
392
+ >
393
+ <Text
394
+ color={history.id === currentHistoryId ? "inverted" : "primary"}
395
+ size="sm"
396
+ truncate
397
+ >
398
+ {history.title ?? "New Chat"}
399
+ </Text>
400
+ <IconButton
401
+ accessibilityLabel={`Delete chat: ${history.title ?? "New Chat"}`}
402
+ iconName="trash"
403
+ onClick={() => onDeleteHistory(history.id)}
404
+ testID={`gpt-delete-history-${history.id}`}
405
+ variant="destructive"
406
+ />
407
+ </Box>
408
+ ))}
409
+ </Box>
410
+
411
+ {/* Chat Panel */}
412
+ <Box direction="column" flex="grow" padding={4}>
413
+ {/* Messages */}
414
+ <Box flex="grow" gap={3} marginBottom={3} overflow="scrollY">
415
+ {currentMessages.map((message, index) => {
416
+ // Tool call/result messages
417
+ if (message.role === "tool-call" && message.toolCall) {
418
+ return (
419
+ <Box alignItems="start" key={`msg-${index}`} maxWidth="80%">
420
+ <ToolCallCard toolCall={message.toolCall} />
421
+ </Box>
422
+ );
423
+ }
424
+ if (message.role === "tool-result" && message.toolResult) {
425
+ return (
426
+ <Box alignItems="start" key={`msg-${index}`} maxWidth="80%">
427
+ <ToolResultCard toolResult={message.toolResult} />
428
+ </Box>
429
+ );
430
+ }
431
+
432
+ const hasImages = message.contentParts?.some((p) => p.type === "image");
433
+ return (
434
+ <Box alignItems={message.role === "user" ? "end" : "start"} key={`msg-${index}`}>
435
+ <Box
436
+ color={message.role === "user" ? "primary" : "neutralLight"}
437
+ maxWidth={hasImages ? "90%" : "80%"}
438
+ padding={3}
439
+ rounding="lg"
440
+ >
441
+ {/* Render content parts (images, files) */}
442
+ {message.contentParts && message.contentParts.length > 0 ? (
443
+ <Box marginBottom={message.content ? 2 : 0}>
444
+ <MessageContentParts
445
+ parts={message.contentParts.filter((p) => p.type !== "text")}
446
+ />
447
+ </Box>
448
+ ) : null}
449
+
450
+ {/* Render text content */}
451
+ {message.role === "assistant" ? (
452
+ <MarkdownView>{message.content}</MarkdownView>
453
+ ) : (
454
+ <Text color={message.role === "user" ? "inverted" : "primary"}>
455
+ {message.content}
456
+ </Text>
457
+ )}
458
+
459
+ {/* Copy button */}
460
+ {message.role === "assistant" ? (
461
+ <Box alignItems="end" marginTop={1}>
462
+ <IconButton
463
+ accessibilityLabel="Copy message"
464
+ iconName="copy"
465
+ onClick={() => handleCopyMessage(message.content)}
466
+ testID={`gpt-copy-msg-${index}`}
467
+ />
468
+ </Box>
469
+ ) : null}
470
+ </Box>
471
+ </Box>
472
+ );
473
+ })}
474
+ {isStreaming ? (
475
+ <Box alignItems="start" padding={2}>
476
+ <Spinner size="sm" />
477
+ </Box>
478
+ ) : null}
479
+ </Box>
480
+
481
+ {/* Scroll to bottom button */}
482
+ {isScrolledUp && isStreaming ? (
483
+ <Box alignItems="center" marginBottom={2}>
484
+ <Button
485
+ iconName="arrow-down"
486
+ onClick={scrollToBottom}
487
+ text="Scroll to bottom"
488
+ variant="outline"
489
+ />
490
+ </Box>
491
+ ) : null}
492
+
493
+ {/* Attachment preview */}
494
+ {attachments.length > 0 && onRemoveAttachment ? (
495
+ <AttachmentPreview attachments={attachments} onRemove={onRemoveAttachment} />
496
+ ) : null}
497
+
498
+ {/* Input */}
499
+ <Box alignItems="end" direction="row" gap={2}>
500
+ {onAttachFiles ? (
501
+ <FilePickerButton
502
+ disabled={isStreaming}
503
+ onFilesSelected={handleFilesSelected}
504
+ testID="gpt-attach-button"
505
+ />
506
+ ) : null}
507
+ <Box flex="grow">
508
+ <TextArea
509
+ disabled={isStreaming}
510
+ onChange={setInputValue}
511
+ placeholder="Type a message..."
512
+ testID="gpt-input"
513
+ value={inputValue}
514
+ />
515
+ </Box>
516
+ <Button
517
+ disabled={!inputValue.trim() || isStreaming}
518
+ iconName="paper-plane"
519
+ onClick={handleSubmit}
520
+ testID="gpt-submit"
521
+ text="Send"
522
+ />
523
+ </Box>
524
+ </Box>
525
+
526
+ {onGeminiApiKeyChange ? (
527
+ <Modal
528
+ onDismiss={() => setIsApiKeyModalVisible(false)}
529
+ primaryButtonOnClick={handleSaveApiKey}
530
+ primaryButtonText="Save"
531
+ secondaryButtonOnClick={() => setIsApiKeyModalVisible(false)}
532
+ secondaryButtonText="Cancel"
533
+ size="sm"
534
+ subtitle="Provide your own Gemini API key for AI requests."
535
+ title="Gemini API Key"
536
+ visible={isApiKeyModalVisible}
537
+ >
538
+ <Box padding={2}>
539
+ <TextField
540
+ onChange={setApiKeyDraft}
541
+ placeholder="Enter Gemini API key..."
542
+ testID="gpt-api-key-input"
543
+ type="password"
544
+ value={apiKeyDraft}
545
+ />
546
+ </Box>
547
+ </Modal>
548
+ ) : null}
549
+ </Box>
550
+ );
551
+ };
@@ -0,0 +1,50 @@
1
+ import React, {useCallback, useState} from "react";
2
+
3
+ import {Box} from "./Box";
4
+ import {Modal} from "./Modal";
5
+ import {TextArea} from "./TextArea";
6
+
7
+ export interface GPTMemoryModalProps {
8
+ memory: string;
9
+ onDismiss: () => void;
10
+ onSave: (memory: string) => void;
11
+ visible: boolean;
12
+ }
13
+
14
+ export const GPTMemoryModal = ({
15
+ memory,
16
+ onDismiss,
17
+ onSave,
18
+ visible,
19
+ }: GPTMemoryModalProps): React.ReactElement => {
20
+ const [value, setValue] = useState(memory);
21
+
22
+ const handleSave = useCallback(() => {
23
+ onSave(value);
24
+ onDismiss();
25
+ }, [onDismiss, onSave, value]);
26
+
27
+ return (
28
+ <Modal
29
+ onDismiss={onDismiss}
30
+ primaryButtonOnClick={handleSave}
31
+ primaryButtonText="Save"
32
+ secondaryButtonOnClick={onDismiss}
33
+ secondaryButtonText="Cancel"
34
+ size="md"
35
+ subtitle="Customize the system prompt for your AI assistant."
36
+ title="System Memory"
37
+ visible={visible}
38
+ >
39
+ <Box padding={2}>
40
+ <TextArea
41
+ onChange={setValue}
42
+ placeholder="Enter system instructions..."
43
+ rows={10}
44
+ testID="gpt-memory-textarea"
45
+ value={value}
46
+ />
47
+ </Box>
48
+ </Modal>
49
+ );
50
+ };
package/src/index.tsx CHANGED
@@ -3,6 +3,8 @@ export type {StyleProp, ViewStyle} from "react-native";
3
3
  export * from "./Accordion";
4
4
  export * from "./ActionSheet";
5
5
  export * from "./AddressField";
6
+ export * from "./AIRequestExplorer";
7
+ export * from "./AttachmentPreview";
6
8
  export * from "./Avatar";
7
9
  export * from "./Badge";
8
10
  export * from "./Banner";
@@ -26,7 +28,10 @@ export {default as EmojiSelector} from "./EmojiSelector";
26
28
  export * from "./ErrorBoundary";
27
29
  export * from "./ErrorPage";
28
30
  export * from "./Field";
31
+ export * from "./FilePickerButton";
29
32
  export * from "./FlatList";
33
+ export * from "./GPTChat";
34
+ export * from "./GPTMemoryModal";
30
35
  export * from "./Heading";
31
36
  export * from "./HeightActionSheet";
32
37
  export * from "./Icon";