paseo-form-render 0.1.0 → 0.2.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/README.md CHANGED
@@ -15,6 +15,8 @@ Interactive forms, dashboards, and metrics rendering plugin for Paseo conversati
15
15
  - **Provider Agnostic**:
16
16
  - Fully compatible with **Devin**, **Claude**, **Antigravity**, **Gemini**, and OpenAI providers.
17
17
  - Automatically injects protocol instructions on agent initialization.
18
+ - **Localized Interface**:
19
+ - Labels, buttons, and the picker and settings screens follow the Paseo app language (English and 简体中文). Form content and the error details are not translated.
18
20
  - **Full Mobile & Hermes Compatibility**:
19
21
  - 100% pure React Native UI primitives (`View`, `Text`, `TextInput`, `Pressable`, `Switch`).
20
22
  - Zero external runtime dependencies in the client bundle.
@@ -1,9 +1,11 @@
1
1
  import type { PluginHostProps } from "@getpaseo/plugin/client";
2
+ import { useTranslation } from "@getpaseo/plugin/client";
2
3
  import React, { useMemo, useState } from "react";
3
4
  import { View, Text, StyleSheet, Pressable, Platform } from "react-native";
4
5
  import { formatIssues } from "../shared/form-errors";
5
6
  import { buildFixMessage } from "../shared/form-fix";
6
7
  import { parseFormBlock, parseMessageContent, type ParsedBlock } from "../shared/parser";
8
+ import { messages } from "./messages";
7
9
  import { PfrFormView } from "./renderer";
8
10
  import { HostMarkdown } from "./host-markdown";
9
11
  import { PfrRpcProvider, type PfrRpcFn } from "./rpc-bridge";
@@ -83,6 +85,7 @@ BlockErrorBoundary.prototype.render = function (this: any) {
83
85
  };
84
86
 
85
87
  const FormErrorBox: React.FC<FormErrorBoxProps> = ({ block, blockNumber, theme, onSubmit }) => {
88
+ const { t } = useTranslation(messages);
86
89
  const [status, setStatus] = useState<"idle" | "sending" | "sent">("idle");
87
90
  const errors = block.errors ?? [];
88
91
 
@@ -107,7 +110,7 @@ const FormErrorBox: React.FC<FormErrorBoxProps> = ({ block, blockNumber, theme,
107
110
  ]}
108
111
  >
109
112
  <Text style={[styles.errorTitle, { color: theme.colors.statusDanger }]}>
110
- [pfr-form Syntax Error]
113
+ {t("errorBox.title")}
111
114
  </Text>
112
115
  {formatIssues(errors).map((line, i) => (
113
116
  <Text key={i} style={[styles.errorDetail, { color: theme.colors.statusDanger }]}>
@@ -135,7 +138,7 @@ const FormErrorBox: React.FC<FormErrorBoxProps> = ({ block, blockNumber, theme,
135
138
  </View>
136
139
  {status === "sent" ? (
137
140
  <Text style={[styles.sentText, { color: theme.colors.statusSuccess }]}>
138
- ✓ Fix requested; the agent will reply with a corrected form
141
+ {t("errorBox.fixRequested")}
139
142
  </Text>
140
143
  ) : (
141
144
  <Pressable
@@ -148,7 +151,7 @@ const FormErrorBox: React.FC<FormErrorBoxProps> = ({ block, blockNumber, theme,
148
151
  onPress={askAgentToFix}
149
152
  >
150
153
  <Text style={[styles.fixButtonText, { color: theme.colors.accentForeground }]}>
151
- {status === "sending" ? "Sending…" : "Ask agent to fix"}
154
+ {status === "sending" ? t("errorBox.sending") : t("errorBox.askFix")}
152
155
  </Text>
153
156
  </Pressable>
154
157
  )}
@@ -0,0 +1,147 @@
1
+ import { defineTranslations } from "@getpaseo/plugin/client";
2
+
3
+ /**
4
+ * Labels shown in the plugin UI. English is the source of truth; every other locale must use the
5
+ * same keys and placeholders (checked by the SDK and by `client/messages.test.ts`). The host picks the
6
+ * locale from the app language.
7
+ */
8
+ const en = {
9
+ slash: {
10
+ form: "Instruct the agent to use pfr-form protocol",
11
+ formDemo: "Show a demo interactive form",
12
+ },
13
+ errorBox: {
14
+ title: "[pfr-form Syntax Error]",
15
+ askFix: "Ask agent to fix",
16
+ sending: "Sending…",
17
+ fixRequested: "✓ Fix requested; the agent will reply with a corrected form",
18
+ },
19
+ form: {
20
+ submitted: "✓ Form Submitted",
21
+ },
22
+ dropdown: {
23
+ placeholder: "Select an option...",
24
+ multiPlaceholder: "Select options...",
25
+ close: "Close dropdown menu",
26
+ },
27
+ file: {
28
+ folderPlaceholder: "Enter or select folder...",
29
+ filePlaceholder: "Enter or select file...",
30
+ browse: "Browse...",
31
+ fullPath: "Full path:",
32
+ },
33
+ picker: {
34
+ selectFolder: "Select Folder",
35
+ selectFile: "Select File",
36
+ chooseFolder: "Choose Folder",
37
+ chooseFile: "Choose File",
38
+ quick: "Quick:",
39
+ searchPlaceholder: "Search files/folders in directory...",
40
+ newFolderPlaceholder: "New folder name...",
41
+ create: "Create",
42
+ upToParent: ".. (Up to parent)",
43
+ loading: "Loading files...",
44
+ noMatches: "No matching entries found",
45
+ selected: "✓ Selected",
46
+ selectedLabel: "Selected:",
47
+ cancel: "Cancel",
48
+ projectRoot: "Project Root",
49
+ project: "Project",
50
+ invalidFolderName: 'Folder name contains invalid characters (/ \\ : * ? " < > |).',
51
+ createFailed: "Failed to create folder.",
52
+ },
53
+ settings: {
54
+ loadError: "Could not load settings: {{error}}",
55
+ retry: "Retry",
56
+ resetDefaults: "Reset to defaults",
57
+ autoFixTitle: "Automatic form fixes",
58
+ autoFixDescription:
59
+ 'When an agent replies with a pfr-form block that fails validation, ask it to re-send a corrected block automatically. This sets how many times in a row the agent is asked; 0 turns automatic fixes off. The "Ask agent to fix" button on an error box always works.',
60
+ maxRetries: "Maximum retries",
61
+ decreaseRetries: "Decrease retries",
62
+ increaseRetries: "Increase retries",
63
+ hintOff: "Automatic fixes are off.",
64
+ hint_one: "Up to {{count}} automatic request per reply.",
65
+ hint_other: "Up to {{count}} automatic requests per reply.",
66
+ saving: "Saving…",
67
+ preferFormsTitle: "Prefer forms",
68
+ preferFormsDescription:
69
+ "By default the agent is only told it can use pfr-form. Turn this on to require it to ask for choices, parameters, and confirmations with a form instead of plain text. Applies to agents created after the change.",
70
+ requireForms: "Require forms for user input",
71
+ saveError: "Could not save: {{error}}",
72
+ },
73
+ };
74
+
75
+ const zhCN = {
76
+ slash: {
77
+ form: "让智能体使用 pfr-form 协议",
78
+ formDemo: "显示一个交互表单示例",
79
+ },
80
+ errorBox: {
81
+ title: "[pfr-form 语法错误]",
82
+ askFix: "请求智能体修复",
83
+ sending: "发送中…",
84
+ fixRequested: "✓ 已请求修复,智能体将回复更正后的表单",
85
+ },
86
+ form: {
87
+ submitted: "✓ 表单已提交",
88
+ },
89
+ dropdown: {
90
+ placeholder: "请选择...",
91
+ multiPlaceholder: "请选择多项...",
92
+ close: "关闭下拉菜单",
93
+ },
94
+ file: {
95
+ folderPlaceholder: "输入或选择文件夹...",
96
+ filePlaceholder: "输入或选择文件...",
97
+ browse: "浏览...",
98
+ fullPath: "完整路径:",
99
+ },
100
+ picker: {
101
+ selectFolder: "选择文件夹",
102
+ selectFile: "选择文件",
103
+ chooseFolder: "选择此文件夹",
104
+ chooseFile: "选择此文件",
105
+ quick: "快捷:",
106
+ searchPlaceholder: "在目录中搜索文件/文件夹...",
107
+ newFolderPlaceholder: "新文件夹名称...",
108
+ create: "创建",
109
+ upToParent: ".. (返回上级)",
110
+ loading: "正在加载文件...",
111
+ noMatches: "没有匹配的条目",
112
+ selected: "✓ 已选择",
113
+ selectedLabel: "已选择:",
114
+ cancel: "取消",
115
+ projectRoot: "项目根目录",
116
+ project: "项目",
117
+ invalidFolderName: '文件夹名称包含非法字符 (/ \\ : * ? " < > |)。',
118
+ createFailed: "创建文件夹失败。",
119
+ },
120
+ settings: {
121
+ loadError: "无法加载设置:{{error}}",
122
+ retry: "重试",
123
+ resetDefaults: "恢复默认值",
124
+ autoFixTitle: "自动修复表单",
125
+ autoFixDescription:
126
+ "当智能体回复的 pfr-form 代码块未通过校验时,自动请求它重新发送更正后的代码块。此处设置连续请求的最多次数;设为 0 则关闭自动修复。错误框上的“请求智能体修复”按钮始终可用。",
127
+ maxRetries: "最大重试次数",
128
+ decreaseRetries: "减少重试次数",
129
+ increaseRetries: "增加重试次数",
130
+ hintOff: "自动修复已关闭。",
131
+ hint_other: "每条回复最多自动请求 {{count}} 次。",
132
+ saving: "保存中…",
133
+ preferFormsTitle: "优先使用表单",
134
+ preferFormsDescription:
135
+ "默认情况下,智能体只被告知可以使用 pfr-form。开启后,它必须通过表单询问选项、参数和确认,而不是纯文本。仅对之后创建的智能体生效。",
136
+ requireForms: "用户输入必须使用表单",
137
+ saveError: "无法保存:{{error}}",
138
+ },
139
+ };
140
+
141
+ export const messages = defineTranslations({
142
+ resources: {
143
+ en,
144
+ "zh-CN": zhCN,
145
+ zh: zhCN,
146
+ },
147
+ });
@@ -1,4 +1,5 @@
1
1
  import type { PluginHostProps } from "@getpaseo/plugin/client";
2
+ import { useTranslation } from "@getpaseo/plugin/client";
2
3
  import React, { useEffect, useMemo, useState } from "react";
3
4
  import {
4
5
  Modal,
@@ -16,6 +17,7 @@ import {
16
17
  type FilePickerShortcut,
17
18
  } from "../shared/pickers";
18
19
  import { normalizeExtensions } from "../shared/schema";
20
+ import { messages } from "./messages";
19
21
  import { usePfrRpc } from "./rpc-bridge";
20
22
 
21
23
  interface ProjectFileModalProps {
@@ -43,6 +45,7 @@ export function ProjectFileModal({
43
45
  }: ProjectFileModalProps) {
44
46
  const listProjectFiles = usePfrRpc(listProjectFilesRpc);
45
47
  const createDir = usePfrRpc(createDirectoryRpc);
48
+ const { t } = useTranslation(messages);
46
49
  const [currentPath, setCurrentPath] = useState("");
47
50
  const [parentPath, setParentPath] = useState<string | null>(null);
48
51
  const [entries, setEntries] = useState<ProjectFileEntry[]>([]);
@@ -112,7 +115,7 @@ export function ProjectFileModal({
112
115
  if (!agentId || !newFolderName.trim() || creatingFolderLoading) return;
113
116
  const name = newFolderName.trim();
114
117
  if (/[<>:"/\\|?*]/.test(name) || name === "." || name === "..") {
115
- setNewFolderError('Folder name contains invalid characters (/ \\ : * ? " < > |).');
118
+ setNewFolderError(t("picker.invalidFolderName"));
116
119
  return;
117
120
  }
118
121
  setCreatingFolderLoading(true);
@@ -132,7 +135,7 @@ export function ProjectFileModal({
132
135
  setSelectedPath(res.relativePath || res.fullPath);
133
136
  }
134
137
  } else {
135
- setNewFolderError(res.error || "Failed to create folder.");
138
+ setNewFolderError(res.error || t("picker.createFailed"));
136
139
  }
137
140
  } catch (err) {
138
141
  setNewFolderError(err instanceof Error ? err.message : String(err));
@@ -159,9 +162,9 @@ export function ProjectFileModal({
159
162
 
160
163
  const breadcrumbs = useMemo(() => {
161
164
  if (isWorkspaceRelative) {
162
- if (!currentPath) return [{ label: "Project Root", path: "" }];
165
+ if (!currentPath) return [{ label: t("picker.projectRoot"), path: "" }];
163
166
  const parts = currentPath.split("/").filter(Boolean);
164
- const crumbs = [{ label: "Project", path: "" }];
167
+ const crumbs = [{ label: t("picker.project"), path: "" }];
165
168
  let accum = "";
166
169
  for (const part of parts) {
167
170
  accum = accum ? `${accum}/${part}` : part;
@@ -190,7 +193,7 @@ export function ProjectFileModal({
190
193
  }
191
194
  return crumbs;
192
195
  }
193
- }, [currentPath, isWorkspaceRelative]);
196
+ }, [currentPath, isWorkspaceRelative, t]);
194
197
 
195
198
  const handleEntryPress = (entry: ProjectFileEntry) => {
196
199
  if (entry.isDirectory) {
@@ -227,7 +230,7 @@ export function ProjectFileModal({
227
230
  <View style={[styles.header, { borderBottomColor: colors.border }]}>
228
231
  <View style={{ flexDirection: "row", alignItems: "center", gap: 8, flex: 1, marginRight: 8 }}>
229
232
  <Text style={[styles.title, { color: colors.foreground }]}>
230
- {pickerType === "folder" ? "Select Folder" : "Select File"}
233
+ {pickerType === "folder" ? t("picker.selectFolder") : t("picker.selectFile")}
231
234
  </Text>
232
235
  {cleanExts && cleanExts.length > 0 && (
233
236
  <View style={[styles.filterBadge, { backgroundColor: colors.surface1, borderColor: colors.border }]}>
@@ -245,7 +248,7 @@ export function ProjectFileModal({
245
248
  {/* Quick Shortcuts Bar */}
246
249
  {shortcuts.length > 0 && (
247
250
  <View style={[styles.shortcutsBar, { borderBottomColor: colors.border, backgroundColor: colors.surface1 }]}>
248
- <Text style={[styles.shortcutsLabel, { color: colors.foregroundMuted }]}>Quick:</Text>
251
+ <Text style={[styles.shortcutsLabel, { color: colors.foregroundMuted }]}>{t("picker.quick")}</Text>
249
252
  <ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.shortcutsList}>
250
253
  {shortcuts.map((sc) => (
251
254
  <Pressable
@@ -298,7 +301,7 @@ export function ProjectFileModal({
298
301
  borderColor: colors.border,
299
302
  },
300
303
  ]}
301
- placeholder="Search files/folders in directory..."
304
+ placeholder={t("picker.searchPlaceholder")}
302
305
  placeholderTextColor={colors.foregroundMuted}
303
306
  value={search}
304
307
  onChangeText={setSearch}
@@ -353,7 +356,7 @@ export function ProjectFileModal({
353
356
  borderColor: colors.border,
354
357
  },
355
358
  ]}
356
- placeholder="New folder name..."
359
+ placeholder={t("picker.newFolderPlaceholder")}
357
360
  placeholderTextColor={colors.foregroundMuted}
358
361
  value={newFolderName}
359
362
  onChangeText={(t) => {
@@ -375,7 +378,7 @@ export function ProjectFileModal({
375
378
  ]}
376
379
  >
377
380
  <Text style={{ color: colors.surface0, fontSize: 12, fontWeight: "600" }}>
378
- {creatingFolderLoading ? "..." : "Create"}
381
+ {creatingFolderLoading ? "..." : t("picker.create")}
379
382
  </Text>
380
383
  </Pressable>
381
384
  <Pressable
@@ -407,16 +410,16 @@ export function ProjectFileModal({
407
410
  onPress={() => loadDirectory(parentPath)}
408
411
  >
409
412
  <Text style={styles.entryIcon}>📁</Text>
410
- <Text style={{ color: colors.foregroundMuted, fontSize: 14 }}>.. (Up to parent)</Text>
413
+ <Text style={{ color: colors.foregroundMuted, fontSize: 14 }}>{t("picker.upToParent")}</Text>
411
414
  </Pressable>
412
415
  )}
413
416
 
414
417
  {loading ? (
415
- <Text style={[styles.emptyText, { color: colors.foregroundMuted }]}>Loading files...</Text>
418
+ <Text style={[styles.emptyText, { color: colors.foregroundMuted }]}>{t("picker.loading")}</Text>
416
419
  ) : error ? (
417
420
  <Text style={[styles.emptyText, { color: colors.statusDanger }]}>{error}</Text>
418
421
  ) : filteredEntries.length === 0 ? (
419
- <Text style={[styles.emptyText, { color: colors.foregroundMuted }]}>No matching entries found</Text>
422
+ <Text style={[styles.emptyText, { color: colors.foregroundMuted }]}>{t("picker.noMatches")}</Text>
420
423
  ) : (
421
424
  filteredEntries.map((entry) => {
422
425
  const isSelected = selectedPath === entry.relativePath;
@@ -445,7 +448,7 @@ export function ProjectFileModal({
445
448
  {entry.name}
446
449
  </Text>
447
450
  {isSelected && (
448
- <Text style={{ color: colors.accent, fontWeight: "600", fontSize: 13 }}>✓ Selected</Text>
451
+ <Text style={{ color: colors.accent, fontWeight: "600", fontSize: 13 }}>{t("picker.selected")}</Text>
449
452
  )}
450
453
  </Pressable>
451
454
  );
@@ -456,7 +459,7 @@ export function ProjectFileModal({
456
459
  {/* Footer */}
457
460
  <View style={[styles.footer, { borderTopColor: colors.border }]}>
458
461
  <View style={{ flex: 1, marginRight: 10, overflow: "hidden" }}>
459
- <Text style={{ color: colors.foregroundMuted, fontSize: 12 }}>Selected:</Text>
462
+ <Text style={{ color: colors.foregroundMuted, fontSize: 12 }}>{t("picker.selectedLabel")}</Text>
460
463
  {effectiveSelected ? (
461
464
  <ScrollView
462
465
  horizontal
@@ -489,7 +492,7 @@ export function ProjectFileModal({
489
492
  onPress={onClose}
490
493
  style={[styles.actionBtn, { borderColor: colors.border, borderWidth: 1 }]}
491
494
  >
492
- <Text style={{ color: colors.foreground, fontSize: 13 }}>Cancel</Text>
495
+ <Text style={{ color: colors.foreground, fontSize: 13 }}>{t("picker.cancel")}</Text>
493
496
  </Pressable>
494
497
 
495
498
  <Pressable
@@ -504,7 +507,7 @@ export function ProjectFileModal({
504
507
  ]}
505
508
  >
506
509
  <Text style={{ color: "#fff", fontWeight: "600", fontSize: 13 }}>
507
- {pickerType === "folder" ? "Choose Folder" : "Choose File"}
510
+ {pickerType === "folder" ? t("picker.chooseFolder") : t("picker.chooseFile")}
508
511
  </Text>
509
512
  </Pressable>
510
513
  </View>
@@ -1,5 +1,5 @@
1
1
  import type { PluginHostProps } from "@getpaseo/plugin/client";
2
- import { useRpc } from "@getpaseo/plugin/client";
2
+ import { useRpc, useTranslation } from "@getpaseo/plugin/client";
3
3
  import React, { createContext, useContext, useRef, useState } from "react";
4
4
  import { View, Text, TextInput, Pressable, StyleSheet, Switch, ScrollView, Platform } from "react-native";
5
5
  import {
@@ -11,6 +11,7 @@ import {
11
11
  normalizeExtensions,
12
12
  } from "../shared/schema";
13
13
  import { extractInitialValues, validateForm, formatSubmissionMessage } from "../shared/form-helpers";
14
+ import { messages } from "./messages";
14
15
  import { ProjectFileModal } from "./project-file-modal";
15
16
 
16
17
  const MENU_WIDTH = 220;
@@ -83,6 +84,7 @@ function DropdownSelect({
83
84
  const buttonRef = useRef<View>(null);
84
85
  const isOpen = menuCtx?.openMenuId === node.id;
85
86
  const selectedOption = node.options.find((opt) => opt.value === value);
87
+ const { t } = useTranslation(messages);
86
88
 
87
89
  return (
88
90
  <View style={styles.fieldContainer}>
@@ -111,7 +113,7 @@ function DropdownSelect({
111
113
  { color: selectedOption ? theme.colors.foreground : theme.colors.foregroundMuted },
112
114
  ]}
113
115
  >
114
- {selectedOption ? selectedOption.label : "Select an option..."}
116
+ {selectedOption ? selectedOption.label : t("dropdown.placeholder")}
115
117
  </Text>
116
118
  <Text style={[styles.dropdownChevron, { color: theme.colors.foregroundMuted }]}>
117
119
  {isOpen ? "▲" : "▼"}
@@ -143,6 +145,7 @@ function DropdownMultiSelect({
143
145
  const buttonRef = useRef<View>(null);
144
146
  const isOpen = menuCtx?.openMenuId === node.id;
145
147
  const selectedOptions = node.options.filter((opt) => value.includes(opt.value));
148
+ const { t } = useTranslation(messages);
146
149
 
147
150
  return (
148
151
  <View style={styles.fieldContainer}>
@@ -174,7 +177,7 @@ function DropdownMultiSelect({
174
177
  >
175
178
  {selectedOptions.length > 0
176
179
  ? selectedOptions.map((o) => o.label).join(", ")
177
- : (node.placeholder ?? "Select options...")}
180
+ : (node.placeholder ?? t("dropdown.multiPlaceholder"))}
178
181
  </Text>
179
182
  <Text style={[styles.dropdownChevron, { color: theme.colors.foregroundMuted }]}>
180
183
  {isOpen ? "▲" : "▼"}
@@ -205,6 +208,7 @@ function FilePickerField({
205
208
  onChange: (val: string) => void;
206
209
  }) {
207
210
  const [modalVisible, setModalVisible] = useState(false);
211
+ const { t } = useTranslation(messages);
208
212
  const colors = theme.colors;
209
213
  const cleanExts = normalizeExtensions(node.extensions, node.extension);
210
214
 
@@ -239,9 +243,7 @@ function FilePickerField({
239
243
  onChangeText={onChange}
240
244
  placeholder={
241
245
  node.placeholder ||
242
- (node.pickerType === "folder"
243
- ? "Enter or select folder..."
244
- : "Enter or select file...")
246
+ (node.pickerType === "folder" ? t("file.folderPlaceholder") : t("file.filePlaceholder"))
245
247
  }
246
248
  placeholderTextColor={colors.foregroundMuted}
247
249
  editable={!disabled}
@@ -262,14 +264,14 @@ function FilePickerField({
262
264
  onPress={() => setModalVisible(true)}
263
265
  style={[styles.pickerBrowseBtn, { backgroundColor: colors.surface1, borderColor: colors.border }]}
264
266
  >
265
- <Text style={{ color: colors.foreground, fontSize: 12, fontWeight: "500" }}>Browse...</Text>
267
+ <Text style={{ color: colors.foreground, fontSize: 12, fontWeight: "500" }}>{t("file.browse")}</Text>
266
268
  </Pressable>
267
269
  </View>
268
270
  </View>
269
271
 
270
272
  {value ? (
271
273
  <View style={[styles.pathInspector, { backgroundColor: colors.surface1, borderColor: colors.border }]}>
272
- <Text style={[styles.pathInspectorLabel, { color: colors.foregroundMuted }]}>Full path:</Text>
274
+ <Text style={[styles.pathInspectorLabel, { color: colors.foregroundMuted }]}>{t("file.fullPath")}</Text>
273
275
  <ScrollView
274
276
  horizontal
275
277
  showsHorizontalScrollIndicator={true}
@@ -608,6 +610,7 @@ export const PfrFormView: React.FC<PfrFormViewProps> = ({ form, theme, agentId,
608
610
  // The dropdown menu floats over the form at its root (measured position), so opening it never moves anything.
609
611
  const [menuPos, setMenuPos] = useState<OpenDropdownMenu | null>(null);
610
612
  const rootRef = useRef<View>(null);
613
+ const { t } = useTranslation(messages);
611
614
 
612
615
  const toggleMenu = (
613
616
  id: string,
@@ -683,13 +686,13 @@ export const PfrFormView: React.FC<PfrFormViewProps> = ({ form, theme, agentId,
683
686
  />
684
687
  {status === "submitted" && (
685
688
  <View style={[styles.submittedBanner, { backgroundColor: theme.colors.surface2, borderColor: theme.colors.statusSuccess, borderWidth: 1 }]}>
686
- <Text style={[styles.submittedText, { color: theme.colors.statusSuccess }]}>✓ Form Submitted</Text>
689
+ <Text style={[styles.submittedText, { color: theme.colors.statusSuccess }]}>{t("form.submitted")}</Text>
687
690
  </View>
688
691
  )}
689
692
  {menuPos ? (
690
693
  <>
691
694
  <Pressable
692
- accessibilityLabel="Close dropdown menu"
695
+ accessibilityLabel={t("dropdown.close")}
693
696
  onPress={() => setMenuPos(null)}
694
697
  style={[StyleSheet.absoluteFill, { zIndex: 999 }]}
695
698
  />
@@ -1,11 +1,13 @@
1
1
  import type { PluginSurfaceProps } from "@getpaseo/plugin/client";
2
- import { useSettings } from "@getpaseo/plugin/client";
2
+ import { useSettings, useTranslation } from "@getpaseo/plugin/client";
3
3
  import React from "react";
4
4
  import { ActivityIndicator, Pressable, StyleSheet, Switch, Text, View } from "react-native";
5
5
  import { MAX_AUTO_FIX_RETRIES, pfrSettings } from "../shared/settings";
6
+ import { messages } from "./messages";
6
7
 
7
8
  export const PfrSettingsScreen: React.FC<PluginSurfaceProps> = ({ theme }) => {
8
9
  const settings = useSettings(pfrSettings);
10
+ const { t } = useTranslation(messages);
9
11
  const { colors } = theme;
10
12
 
11
13
  if (settings.status === "loading") {
@@ -20,20 +22,20 @@ export const PfrSettingsScreen: React.FC<PluginSurfaceProps> = ({ theme }) => {
20
22
  return (
21
23
  <View style={styles.container}>
22
24
  <Text style={[styles.errorText, { color: colors.statusDanger }]}>
23
- Could not load settings: {settings.error}
25
+ {t("settings.loadError", { error: settings.error })}
24
26
  </Text>
25
27
  <Pressable
26
28
  style={[styles.stepButton, styles.retryButton, { borderColor: colors.border, backgroundColor: colors.surface2 }]}
27
29
  onPress={() => void settings.reload()}
28
30
  >
29
- <Text style={[styles.stepText, { color: colors.foreground }]}>Retry</Text>
31
+ <Text style={[styles.stepText, { color: colors.foreground }]}>{t("settings.retry")}</Text>
30
32
  </Pressable>
31
33
  {settings.status === "invalid" && (
32
34
  <Pressable
33
35
  style={[styles.stepButton, styles.retryButton, { borderColor: colors.border, backgroundColor: colors.surface2 }]}
34
36
  onPress={() => void settings.reset()}
35
37
  >
36
- <Text style={[styles.stepText, { color: colors.foreground }]}>Reset to defaults</Text>
38
+ <Text style={[styles.stepText, { color: colors.foreground }]}>{t("settings.resetDefaults")}</Text>
37
39
  </Pressable>
38
40
  )}
39
41
  </View>
@@ -53,18 +55,14 @@ export const PfrSettingsScreen: React.FC<PluginSurfaceProps> = ({ theme }) => {
53
55
 
54
56
  return (
55
57
  <View style={styles.container}>
56
- <Text style={[styles.title, { color: colors.foreground }]}>Automatic form fixes</Text>
57
- <Text style={[styles.description, { color: colors.foregroundMuted }]}>
58
- When an agent replies with a pfr-form block that fails validation, ask it to re-send a corrected block
59
- automatically. This sets how many times in a row the agent is asked; 0 turns automatic fixes off. The
60
- "Ask agent to fix" button on an error box always works.
61
- </Text>
58
+ <Text style={[styles.title, { color: colors.foreground }]}>{t("settings.autoFixTitle")}</Text>
59
+ <Text style={[styles.description, { color: colors.foregroundMuted }]}>{t("settings.autoFixDescription")}</Text>
62
60
 
63
61
  <View style={styles.row}>
64
- <Text style={[styles.label, { color: colors.foreground }]}>Maximum retries</Text>
62
+ <Text style={[styles.label, { color: colors.foreground }]}>{t("settings.maxRetries")}</Text>
65
63
  <View style={styles.stepper}>
66
64
  <Pressable
67
- accessibilityLabel="Decrease retries"
65
+ accessibilityLabel={t("settings.decreaseRetries")}
68
66
  disabled={value <= 0 || settings.saving}
69
67
  style={stepStyle(value <= 0 || settings.saving)}
70
68
  onPress={() => change(value - 1)}
@@ -73,7 +71,7 @@ export const PfrSettingsScreen: React.FC<PluginSurfaceProps> = ({ theme }) => {
73
71
  </Pressable>
74
72
  <Text style={[styles.value, { color: colors.foreground }]}>{value}</Text>
75
73
  <Pressable
76
- accessibilityLabel="Increase retries"
74
+ accessibilityLabel={t("settings.increaseRetries")}
77
75
  disabled={value >= MAX_AUTO_FIX_RETRIES || settings.saving}
78
76
  style={stepStyle(value >= MAX_AUTO_FIX_RETRIES || settings.saving)}
79
77
  onPress={() => change(value + 1)}
@@ -84,26 +82,23 @@ export const PfrSettingsScreen: React.FC<PluginSurfaceProps> = ({ theme }) => {
84
82
  </View>
85
83
 
86
84
  <Text style={[styles.hint, { color: colors.foregroundMuted }]}>
87
- {value === 0 ? "Automatic fixes are off." : `Up to ${value} automatic ${value === 1 ? "request" : "requests"} per reply.`}
88
- {settings.saving ? " Saving…" : ""}
85
+ {value === 0 ? t("settings.hintOff") : t("settings.hint", { count: value })}
86
+ {settings.saving ? ` ${t("settings.saving")}` : ""}
89
87
  </Text>
90
88
 
91
- <Text style={[styles.title, { color: colors.foreground }]}>Prefer forms</Text>
92
- <Text style={[styles.description, { color: colors.foregroundMuted }]}>
93
- By default the agent is only told it can use pfr-form. Turn this on to require it to ask for choices,
94
- parameters, and confirmations with a form instead of plain text. Applies to agents created after the change.
95
- </Text>
89
+ <Text style={[styles.title, { color: colors.foreground }]}>{t("settings.preferFormsTitle")}</Text>
90
+ <Text style={[styles.description, { color: colors.foregroundMuted }]}>{t("settings.preferFormsDescription")}</Text>
96
91
  <View style={styles.row}>
97
- <Text style={[styles.label, { color: colors.foreground }]}>Require forms for user input</Text>
92
+ <Text style={[styles.label, { color: colors.foreground }]}>{t("settings.requireForms")}</Text>
98
93
  <Switch
99
- accessibilityLabel="Require forms for user input"
94
+ accessibilityLabel={t("settings.requireForms")}
100
95
  value={settings.values.preferForms}
101
96
  disabled={settings.saving}
102
97
  onValueChange={(next) => void settings.save({ ...settings.values, preferForms: next }, settings.revision)}
103
98
  />
104
99
  </View>
105
100
  {settings.saveError && (
106
- <Text style={[styles.errorText, { color: colors.statusDanger }]}>Could not save: {settings.saveError}</Text>
101
+ <Text style={[styles.errorText, { color: colors.statusDanger }]}>{t("settings.saveError", { error: settings.saveError })}</Text>
107
102
  )}
108
103
  </View>
109
104
  );
package/index.client.tsx CHANGED
@@ -5,6 +5,7 @@ import { Platform } from "react-native";
5
5
  import { z } from "zod";
6
6
  import { hasFormBlock } from "./shared/parser";
7
7
  import { MessageContainer } from "./client/container";
8
+ import { messages } from "./client/messages";
8
9
  import { PfrSettingsScreen } from "./client/settings-screen";
9
10
  import { showDemoForm } from "./shared/demo";
10
11
  import { PFR_PROMPT_INSTRUCTION } from "./shared/prompt";
@@ -78,7 +79,7 @@ export default function contribute(client: PluginClientContext) {
78
79
  // Slash command to enable pfr-form in an existing agent session
79
80
  client.addSlashCommand({
80
81
  name: "form",
81
- description: "Instruct the agent to use pfr-form protocol",
82
+ description: messages.message("slash.form"),
82
83
  argumentHint: "[task]",
83
84
  context: "agent",
84
85
  async onSubmit({ args, paseo, agent }) {
@@ -90,7 +91,7 @@ export default function contribute(client: PluginClientContext) {
90
91
  // Slash command to show a sample form
91
92
  client.addSlashCommand({
92
93
  name: "form-demo",
93
- description: "Show a demo interactive form",
94
+ description: messages.message("slash.formDemo"),
94
95
  argumentHint: "",
95
96
  context: "agent",
96
97
  async onSubmit({ rpc, agent }) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "paseo-form-render",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Interactive forms, dashboards, and metric displays in Paseo conversations using pfr-form code fences.",
5
5
  "license": "MIT",
6
6
  "files": [
@@ -21,8 +21,8 @@
21
21
  "e2e": "npm --prefix e2e run e2e --"
22
22
  },
23
23
  "devDependencies": {
24
- "@getpaseo/client": "^0.11.2",
25
- "@getpaseo/plugin": "^0.11.2",
24
+ "@getpaseo/client": "^0.13.0",
25
+ "@getpaseo/plugin": "^0.13.0",
26
26
  "@tanstack/react-query": "^5.90.11",
27
27
  "@types/node": "^22.20.4",
28
28
  "@types/react": "~19.2.0",
package/paseo-plugin.json CHANGED
@@ -3,6 +3,6 @@
3
3
  "name": "Paseo Form Render",
4
4
  "description": "Render interactive forms, metrics, and dashboards in conversations using pfr-form code blocks",
5
5
  "requirements": {
6
- "paseo": ">=0.11.4"
6
+ "paseo": ">=0.13.0"
7
7
  }
8
8
  }
@@ -147,10 +147,29 @@ export function registerAutoFix(server: PluginServerContext, settings: PfrSettin
147
147
  return (await readPfrSettings(settings)).autoFixMaxRetries;
148
148
  }
149
149
 
150
+ /** The newest history of the agent, or null when it cannot be read (the caller then sends nothing). */
151
+ async function readTail(agent: {
152
+ timeline: { refetch(options: { direction: "tail"; limit: number; projection: "projected" }): Promise<unknown> };
153
+ }): Promise<TimelineEntry[] | null> {
154
+ try {
155
+ const history = timelineFromPayload(
156
+ await agent.timeline.refetch({ direction: "tail", limit: HISTORY_LIMIT, projection: "projected" }),
157
+ );
158
+ return history?.items ?? null;
159
+ } catch (error) {
160
+ console.warn("[paseo-form-render] could not fetch agent history, skipping auto-fix", error);
161
+ return null;
162
+ }
163
+ }
164
+
150
165
  return server.on("agent.turn_ended", async (event, { paseo, signal }) => {
151
166
  try {
167
+ const agent = paseo.agents.ref(event.agent.id);
168
+ // Paseo 0.13 sends an always-empty `timeline` on this event (deprecated), so the finished turn is read from history.
169
+ const turnTimeline = event.timeline.length > 0 ? event.timeline : await readTail(agent);
170
+ if (!turnTimeline) return;
152
171
  const turn: TurnInput = {
153
- turnTimeline: event.timeline,
172
+ turnTimeline,
154
173
  outcome: event.outcome,
155
174
  maxRetries: await readMaxRetries(),
156
175
  };
@@ -161,7 +180,6 @@ export function registerAutoFix(server: PluginServerContext, settings: PfrSettin
161
180
  await new Promise<void>((resolve) => setTimeout(resolve, 0));
162
181
  if (signal.aborted) return;
163
182
 
164
- const agent = paseo.agents.ref(event.agent.id);
165
183
  let history: ReturnType<typeof timelineFromPayload> = null;
166
184
  try {
167
185
  history = timelineFromPayload(