@perspective-dev/viewer 5.0.0 → 5.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.
Files changed (213) hide show
  1. package/dist/cdn/perspective-viewer.js +9 -9
  2. package/dist/cdn/perspective-viewer.js.map +4 -4
  3. package/dist/css/botanical.css +1 -1
  4. package/dist/css/dracula.css +1 -1
  5. package/dist/css/gruvbox-dark.css +1 -1
  6. package/dist/css/gruvbox.css +1 -1
  7. package/dist/css/icons.css +1 -1
  8. package/dist/css/intl/de.css +1 -1
  9. package/dist/css/intl/es.css +1 -1
  10. package/dist/css/intl/fr.css +1 -1
  11. package/dist/css/intl/ja.css +1 -1
  12. package/dist/css/intl/pt.css +1 -1
  13. package/dist/css/intl/zh.css +1 -1
  14. package/dist/css/intl.css +1 -1
  15. package/dist/css/monokai.css +1 -1
  16. package/dist/css/phosphor.css +1 -1
  17. package/dist/css/pro-dark.css +1 -1
  18. package/dist/css/pro.css +1 -1
  19. package/dist/css/solarized-dark.css +1 -1
  20. package/dist/css/solarized.css +1 -1
  21. package/dist/css/themes.css +1 -1
  22. package/dist/css/vaporwave.css +1 -1
  23. package/dist/docs/perspective-docs.json +1 -0
  24. package/dist/esm/column-format.d.ts +19 -28
  25. package/dist/esm/perspective-viewer.d.ts +2 -0
  26. package/dist/esm/perspective-viewer.inline.js +8 -8
  27. package/dist/esm/perspective-viewer.inline.js.map +4 -4
  28. package/dist/esm/perspective-viewer.js +9 -9
  29. package/dist/esm/perspective-viewer.js.map +4 -4
  30. package/dist/esm/providers.d.ts +79 -0
  31. package/dist/esm/ts-rs/CompactDisplay.d.ts +5 -0
  32. package/dist/esm/ts-rs/CurrencyCode.d.ts +1 -0
  33. package/dist/esm/ts-rs/CurrencyDisplay.d.ts +1 -0
  34. package/dist/esm/ts-rs/CurrencyNumberFormatStyle.d.ts +8 -0
  35. package/dist/esm/ts-rs/CurrencySign.d.ts +1 -0
  36. package/dist/esm/ts-rs/CustomDatetimeFormat.d.ts +1 -0
  37. package/dist/esm/ts-rs/CustomDatetimeStyleConfig.d.ts +54 -0
  38. package/dist/esm/ts-rs/CustomNumberFormatConfig.d.ts +34 -0
  39. package/dist/esm/ts-rs/DatetimeColorMode.d.ts +1 -0
  40. package/dist/esm/ts-rs/DatetimeFormatType.d.ts +6 -0
  41. package/dist/esm/ts-rs/FormatMode.d.ts +1 -0
  42. package/dist/esm/ts-rs/FormatUnit.d.ts +1 -0
  43. package/dist/esm/ts-rs/Notation.d.ts +17 -0
  44. package/dist/esm/ts-rs/NumberFormatStyle.d.ts +18 -0
  45. package/dist/esm/ts-rs/PanelViewerConfig.d.ts +43 -1
  46. package/dist/esm/ts-rs/PluginStaticConfig.d.ts +35 -0
  47. package/dist/esm/ts-rs/RestoreOptions.d.ts +19 -0
  48. package/dist/esm/ts-rs/RoundingMode.d.ts +1 -0
  49. package/dist/esm/ts-rs/RoundingPriority.d.ts +1 -0
  50. package/dist/esm/ts-rs/SignDisplay.d.ts +1 -0
  51. package/dist/esm/ts-rs/SimpleDatetimeFormat.d.ts +1 -0
  52. package/dist/esm/ts-rs/SimpleDatetimeStyleConfig.d.ts +24 -0
  53. package/dist/esm/ts-rs/SplitRollupMode.d.ts +9 -0
  54. package/dist/esm/ts-rs/StringColorMode.d.ts +1 -0
  55. package/dist/esm/ts-rs/TrailingZeroDisplay.d.ts +1 -0
  56. package/dist/esm/ts-rs/Unit.d.ts +1 -0
  57. package/dist/esm/ts-rs/UnitDisplay.d.ts +1 -0
  58. package/dist/esm/ts-rs/UnitNumberFormatStyle.d.ts +6 -0
  59. package/dist/esm/ts-rs/UseGrouping.d.ts +1 -0
  60. package/dist/esm/ts-rs/ViewerConfig.d.ts +43 -1
  61. package/dist/esm/ts-rs/ViewerConfigInitial.d.ts +175 -0
  62. package/dist/esm/ts-rs/ViewerConfigUpdate.d.ts +72 -0
  63. package/dist/esm/ts-rs/WindowSort.d.ts +7 -0
  64. package/dist/esm/ts-rs/WindowSortDir.d.ts +6 -0
  65. package/dist/esm/ts-rs/WindowSpec.d.ts +47 -0
  66. package/dist/esm/ts-rs/Windows.d.ts +10 -0
  67. package/dist/esm/ts-rs/WorkspaceConfigUpdate.d.ts +7 -6
  68. package/dist/wasm/perspective-viewer.d.ts +222 -26
  69. package/dist/wasm/perspective-viewer.js +378 -125
  70. package/dist/wasm/perspective-viewer.wasm +0 -0
  71. package/dist/wasm/perspective-viewer.wasm.d.ts +18 -15
  72. package/package.json +2 -1
  73. package/src/css/_viewer_bundle.css +1 -0
  74. package/src/css/column-selector.css +9 -0
  75. package/src/css/column-settings-panel.css +67 -2
  76. package/src/css/column-style.css +4 -3
  77. package/src/css/config-selector.css +60 -4
  78. package/src/css/containers/context-menu.css +12 -6
  79. package/src/css/containers/tabs.css +2 -2
  80. package/src/css/form/code-editor.css +2 -3
  81. package/src/css/form/mirrored-textarea.css +81 -0
  82. package/src/css/viewer.css +336 -0
  83. package/src/rust/agent/client/mod.rs +19 -0
  84. package/src/rust/agent/client/protocol.rs +320 -0
  85. package/src/rust/agent/client/transport.rs +287 -0
  86. package/src/rust/agent/client/turn.rs +174 -0
  87. package/src/rust/agent/config.rs +159 -0
  88. package/src/rust/agent/docs.rs +293 -0
  89. package/src/rust/agent/markdown/block.rs +509 -0
  90. package/src/rust/agent/markdown/entity.rs +52 -0
  91. package/src/rust/agent/markdown/events.rs +71 -0
  92. package/src/rust/agent/markdown/inline.rs +497 -0
  93. package/src/rust/agent/markdown/mod.rs +466 -0
  94. package/src/rust/agent/mod.rs +23 -0
  95. package/src/rust/agent/preamble.md +16 -0
  96. package/src/rust/agent/runtime.rs +201 -0
  97. package/src/rust/agent/slot.rs +236 -0
  98. package/src/rust/agent/tools.rs +1110 -0
  99. package/src/rust/components/chat_panel.rs +289 -0
  100. package/src/rust/components/column_selector/active_column.rs +21 -27
  101. package/src/rust/components/column_selector/column_selector_column_row.rs +73 -0
  102. package/src/rust/components/column_selector/config_selector.rs +83 -8
  103. package/src/rust/components/column_selector/expr_edit_button.rs +7 -1
  104. package/src/rust/components/column_selector/inactive_column.rs +8 -1
  105. package/src/rust/components/column_selector.rs +27 -3
  106. package/src/rust/components/column_settings_sidebar/style_tab.rs +2 -1
  107. package/src/rust/components/column_settings_sidebar/window_tab.rs +34 -0
  108. package/src/rust/components/column_settings_sidebar.rs +163 -16
  109. package/src/rust/components/containers/dragdrop_list.rs +110 -3
  110. package/src/rust/components/containers/sidebar.rs +99 -22
  111. package/src/rust/components/containers/split_panel.rs +6 -0
  112. package/src/rust/components/editable_header.rs +2 -0
  113. package/src/rust/components/form/code_editor.rs +14 -23
  114. package/src/rust/components/form/debug.rs +8 -5
  115. package/src/rust/components/form/mirrored_textarea.rs +165 -0
  116. package/src/rust/components/form/mod.rs +1 -0
  117. package/src/rust/components/main_panel.rs +5 -0
  118. package/src/rust/components/mod.rs +3 -0
  119. package/src/rust/components/panel_menu.rs +7 -0
  120. package/src/rust/components/panel_tab.rs +5 -0
  121. package/src/rust/components/portal.rs +7 -0
  122. package/src/rust/components/settings_panel.rs +80 -2
  123. package/src/rust/components/viewer/msg.rs +2 -0
  124. package/src/rust/components/viewer/panels.rs +9 -14
  125. package/src/rust/components/viewer/render.rs +14 -0
  126. package/src/rust/components/viewer/settings.rs +33 -1
  127. package/src/rust/components/viewer.rs +2 -0
  128. package/src/rust/components/window_editor.rs +985 -0
  129. package/src/rust/config/datetime_column_style/custom.rs +17 -0
  130. package/src/rust/config/datetime_column_style/simple.rs +10 -0
  131. package/src/rust/config/number_string_format.rs +33 -0
  132. package/src/rust/config/options.rs +18 -0
  133. package/src/rust/config/plugin_static_config.rs +120 -5
  134. package/src/rust/config/viewer_config.rs +220 -1
  135. package/src/rust/config/workspace_config.rs +7 -6
  136. package/src/rust/custom_elements/viewer.rs +209 -18
  137. package/src/rust/custom_events.rs +29 -0
  138. package/src/rust/lib.rs +17 -1
  139. package/src/rust/presentation/column_locator.rs +16 -1
  140. package/src/rust/presentation.rs +8 -0
  141. package/src/rust/queries/columns_iter_set.rs +7 -1
  142. package/src/rust/queries/get_viewer_config.rs +5 -1
  143. package/src/rust/renderer/dispatch.rs +85 -58
  144. package/src/rust/renderer.rs +34 -1
  145. package/src/rust/session/column_defaults_update.rs +18 -0
  146. package/src/rust/session/drag_drop_update.rs +18 -0
  147. package/src/rust/session/metadata.rs +64 -9
  148. package/src/rust/session/replace_expression_update.rs +2 -0
  149. package/src/rust/session.rs +7 -5
  150. package/src/rust/tasks/create_panel.rs +18 -8
  151. package/src/rust/tasks/edit_window.rs +155 -0
  152. package/src/rust/tasks/mod.rs +2 -0
  153. package/src/rust/tasks/restore_panel.rs +34 -17
  154. package/src/rust/tasks/sync_update_panels.rs +7 -23
  155. package/src/rust/utils/browser/dragdrop.rs +12 -0
  156. package/src/rust/utils/browser/tests/debounce.rs +49 -0
  157. package/src/rust/utils/debounce.rs +76 -27
  158. package/src/rust/workspace.rs +112 -20
  159. package/src/svg/pin-icon.svg +5 -0
  160. package/src/themes/icons.css +1 -0
  161. package/src/themes/intl/de.css +23 -0
  162. package/src/themes/intl/es.css +23 -0
  163. package/src/themes/intl/fr.css +23 -0
  164. package/src/themes/intl/ja.css +23 -0
  165. package/src/themes/intl/pt.css +23 -0
  166. package/src/themes/intl/zh.css +23 -0
  167. package/src/themes/intl.css +26 -1
  168. package/src/ts/column-format.ts +67 -46
  169. package/src/ts/perspective-viewer.ts +2 -0
  170. package/src/ts/providers.ts +96 -0
  171. package/src/ts/ts-rs/CompactDisplay.ts +3 -0
  172. package/src/ts/ts-rs/CurrencyCode.ts +3 -0
  173. package/src/ts/ts-rs/CurrencyDisplay.ts +3 -0
  174. package/src/ts/ts-rs/CurrencyNumberFormatStyle.ts +6 -0
  175. package/src/ts/ts-rs/CurrencySign.ts +3 -0
  176. package/src/ts/ts-rs/CustomDatetimeFormat.ts +3 -0
  177. package/src/ts/ts-rs/CustomDatetimeStyleConfig.ts +54 -0
  178. package/src/ts/ts-rs/CustomNumberFormatConfig.ts +25 -0
  179. package/src/ts/ts-rs/DatetimeColorMode.ts +3 -0
  180. package/src/ts/ts-rs/DatetimeFormatType.ts +8 -0
  181. package/src/ts/ts-rs/FormatMode.ts +3 -0
  182. package/src/ts/ts-rs/FormatUnit.ts +3 -0
  183. package/src/ts/ts-rs/Notation.ts +11 -0
  184. package/src/ts/ts-rs/NumberFormatStyle.ts +12 -0
  185. package/src/ts/ts-rs/PanelViewerConfig.ts +48 -1
  186. package/src/ts/ts-rs/PluginStaticConfig.ts +36 -1
  187. package/src/ts/ts-rs/RestoreOptions.ts +20 -0
  188. package/src/ts/ts-rs/RoundingMode.ts +3 -0
  189. package/src/ts/ts-rs/RoundingPriority.ts +3 -0
  190. package/src/ts/ts-rs/SignDisplay.ts +3 -0
  191. package/src/ts/ts-rs/SimpleDatetimeFormat.ts +3 -0
  192. package/src/ts/ts-rs/SimpleDatetimeStyleConfig.ts +25 -0
  193. package/src/ts/ts-rs/SplitRollupMode.ts +11 -0
  194. package/src/ts/ts-rs/StringColorMode.ts +3 -0
  195. package/src/ts/ts-rs/TrailingZeroDisplay.ts +3 -0
  196. package/src/ts/ts-rs/Unit.ts +3 -0
  197. package/src/ts/ts-rs/UnitDisplay.ts +3 -0
  198. package/src/ts/ts-rs/UnitNumberFormatStyle.ts +5 -0
  199. package/src/ts/ts-rs/UseGrouping.ts +3 -0
  200. package/src/ts/ts-rs/ViewerConfig.ts +48 -1
  201. package/src/ts/ts-rs/ViewerConfigInitial.ts +164 -0
  202. package/src/ts/ts-rs/ViewerConfigUpdate.ts +81 -2
  203. package/src/ts/ts-rs/WindowSort.ts +9 -0
  204. package/src/ts/ts-rs/WindowSortDir.ts +8 -0
  205. package/src/ts/ts-rs/WindowSpec.ts +47 -0
  206. package/src/ts/ts-rs/Windows.ts +10 -0
  207. package/src/ts/ts-rs/WorkspaceConfigUpdate.ts +7 -6
  208. /package/dist/wasm/snippets/{perspective-viewer-daeef4ceb066e6c7 → perspective-viewer-02e461f3d7062b26}/inline0.js +0 -0
  209. /package/dist/wasm/snippets/{perspective-viewer-daeef4ceb066e6c7 → perspective-viewer-02e461f3d7062b26}/inline1.js +0 -0
  210. /package/dist/wasm/snippets/{perspective-viewer-daeef4ceb066e6c7 → perspective-viewer-02e461f3d7062b26}/inline2.js +0 -0
  211. /package/dist/wasm/snippets/{perspective-viewer-daeef4ceb066e6c7 → perspective-viewer-02e461f3d7062b26}/inline3.js +0 -0
  212. /package/dist/wasm/snippets/{perspective-viewer-daeef4ceb066e6c7 → perspective-viewer-02e461f3d7062b26}/inline4.js +0 -0
  213. /package/dist/wasm/snippets/{perspective-viewer-daeef4ceb066e6c7 → perspective-viewer-02e461f3d7062b26}/inline5.js +0 -0
@@ -0,0 +1,1110 @@
1
+ // ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
2
+ // ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
3
+ // ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
4
+ // ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
5
+ // ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
6
+ // ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
7
+ // ┃ Copyright (c) 2017, the Perspective Authors. ┃
8
+ // ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
9
+ // ┃ This file is part of the Perspective library, distributed under the terms ┃
10
+ // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
11
+ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
12
+
13
+ use std::collections::HashMap;
14
+ use std::rc::Rc;
15
+ use std::str::FromStr;
16
+
17
+ use futures::future::LocalBoxFuture;
18
+ use perspective_js::utils::ApiError;
19
+ use serde::Deserialize;
20
+ use serde_json::{Value, json};
21
+ use wasm_bindgen::prelude::*;
22
+
23
+ use super::client::protocol::{FunctionDef, ToolDef};
24
+ use super::docs::{DocsBundle, DocsCell};
25
+ use crate::config::{ColumnConfigSchema, ControlSpec};
26
+ use crate::custom_elements::viewer::PerspectiveViewerElement;
27
+ use crate::queries::{
28
+ get_column_config_schema, get_plugin_config_schema, get_viewer_config, validate_expr,
29
+ };
30
+ use crate::workspace::{Panel, PanelId};
31
+
32
+ /// Host-grantable access classes (`agentConfig({entitlements})`). A tool
33
+ /// whose entitlement is withheld is neither advertised nor dispatchable.
34
+ #[derive(Clone, Copy, Debug, PartialEq)]
35
+ pub enum Entitlement {
36
+ ReadView,
37
+ ConfigureView,
38
+ ManageLayout,
39
+ ReadDocs,
40
+
41
+ // TODO(texodus): unused
42
+ ReadData,
43
+ }
44
+
45
+ impl Entitlement {
46
+ pub const VALID: &'static str =
47
+ "read_view, configure_view, manage_layout, read_docs, read_data";
48
+
49
+ /// The default grant: everything except raw data access.
50
+ pub fn default_set() -> Vec<Self> {
51
+ vec![
52
+ Self::ReadView,
53
+ Self::ConfigureView,
54
+ Self::ManageLayout,
55
+ Self::ReadDocs,
56
+ ]
57
+ }
58
+ }
59
+
60
+ impl FromStr for Entitlement {
61
+ type Err = String;
62
+
63
+ fn from_str(value: &str) -> Result<Self, String> {
64
+ match value {
65
+ "read_view" => Ok(Self::ReadView),
66
+ "configure_view" => Ok(Self::ConfigureView),
67
+ "manage_layout" => Ok(Self::ManageLayout),
68
+ "read_docs" => Ok(Self::ReadDocs),
69
+ "read_data" => Ok(Self::ReadData),
70
+ other => Err(format!(
71
+ "Unknown entitlement `{other}` - valid entitlements: {}",
72
+ Self::VALID
73
+ )),
74
+ }
75
+ }
76
+ }
77
+
78
+ /// Per-turn tool context, constructed by the runtime.
79
+ pub struct ToolCtx {
80
+ pub docs: Option<Rc<DocsCell>>,
81
+ pub bundle: Option<Rc<Result<DocsBundle, String>>>,
82
+ pub entitlements: Vec<Entitlement>,
83
+ }
84
+
85
+ impl ToolCtx {
86
+ fn schemas(&self) -> Option<&HashMap<String, Value>> {
87
+ self.bundle
88
+ .as_ref()
89
+ .and_then(|x| x.as_ref().as_ref().ok())
90
+ .map(|x| &x.schemas)
91
+ }
92
+ }
93
+
94
+ pub struct Tool {
95
+ name: &'static str,
96
+ description: &'static str,
97
+ entitlement: Entitlement,
98
+ available: fn(&ToolCtx) -> bool,
99
+ parameters: fn(Option<&HashMap<String, Value>>) -> Value,
100
+ call: for<'a> fn(
101
+ &'a PerspectiveViewerElement,
102
+ &'a ToolCtx,
103
+ Value,
104
+ ) -> LocalBoxFuture<'a, Result<Value, ToolError>>,
105
+ }
106
+
107
+ fn always(_: &ToolCtx) -> bool {
108
+ true
109
+ }
110
+
111
+ fn panel_property() -> Value {
112
+ json!({
113
+ "type": "string",
114
+ "description": "Panel id from `list_panels`; omit to target the active panel"
115
+ })
116
+ }
117
+
118
+ /// The `config` property node + root `definitions` for tools taking a
119
+ /// config argument (`key` = the generated schema's type name, e.g.
120
+ /// `"ViewerConfigUpdate"`): the generated schema (from the docs metadata
121
+ /// bundle) when available, else a permissive object. `require` lists
122
+ /// config properties the AGENT contract requires beyond the public type
123
+ /// (unioned into the definition's `required`, stated outright in the
124
+ /// fallback) — the tool body must enforce the same fields at dispatch.
125
+ fn spliced_config(
126
+ schemas: Option<&HashMap<String, Value>>,
127
+ key: &str,
128
+ description: &str,
129
+ require: &[&str],
130
+ ) -> (Value, Option<Value>) {
131
+ match schemas.and_then(|x| x.get(key)) {
132
+ Some(schema) => {
133
+ let mut definitions = schema
134
+ .get("definitions")
135
+ .and_then(|x| x.as_object())
136
+ .cloned()
137
+ .unwrap_or_default();
138
+
139
+ let original = definitions.remove(key);
140
+ let mut config = original
141
+ .clone()
142
+ .unwrap_or_else(|| json!({ "type": "object" }));
143
+
144
+ let mut required = config["required"].as_array().cloned().unwrap_or_default();
145
+ for field in require {
146
+ if !required.iter().any(|x| x == field) {
147
+ required.push(json!(field));
148
+ }
149
+ }
150
+
151
+ if !required.is_empty() {
152
+ config["required"] = Value::Array(required);
153
+ }
154
+
155
+ config["description"] = json!(description);
156
+
157
+ // A self-recursive config type still needs its own entry.
158
+ if let Some(original) = original
159
+ && serde_json::to_string(&definitions)
160
+ .unwrap_or_default()
161
+ .contains(&format!("#/definitions/{key}"))
162
+ {
163
+ definitions.insert(key.to_owned(), original);
164
+ }
165
+
166
+ let definitions = (!definitions.is_empty()).then(|| Value::Object(definitions));
167
+ (config, definitions)
168
+ },
169
+ None if require.is_empty() => (
170
+ json!({ "type": "object", "description": description }),
171
+ None,
172
+ ),
173
+ None => (
174
+ json!({ "type": "object", "description": description, "required": require }),
175
+ None,
176
+ ),
177
+ }
178
+ }
179
+
180
+ fn config_parameters(
181
+ schemas: Option<&HashMap<String, Value>>,
182
+ key: &str,
183
+ description: &str,
184
+ with_panel: bool,
185
+ require: &[&str],
186
+ ) -> Value {
187
+ let (config, definitions) = spliced_config(schemas, key, description, require);
188
+ let mut properties = json!({ "config": config });
189
+ if with_panel {
190
+ properties["panel"] = panel_property();
191
+ }
192
+
193
+ let mut parameters = json!({
194
+ "type": "object",
195
+ "properties": properties,
196
+ "required": ["config"]
197
+ });
198
+
199
+ if let Some(definitions) = definitions {
200
+ parameters["definitions"] = definitions;
201
+ }
202
+
203
+ parameters
204
+ }
205
+
206
+ static TOOLS: &[Tool] = &[
207
+ Tool {
208
+ name: "get_schema",
209
+ description: "Get the column schema (column name -> type) and row count of the data \
210
+ currently loaded in a panel. Call this before configuring the view - column \
211
+ names must match exactly.",
212
+ entitlement: Entitlement::ReadView,
213
+ available: always,
214
+ parameters: |_| {
215
+ json!({
216
+ "type": "object",
217
+ "properties": { "panel": panel_property() }
218
+ })
219
+ },
220
+ call: |elem, ctx, args| Box::pin(elem.tool_get_schema(ctx, args)),
221
+ },
222
+ Tool {
223
+ name: "get_view_config",
224
+ description: "Get a panel's current configuration (plugin, group_by, split_by, columns, \
225
+ filter, sort, aggregates, expressions).",
226
+ entitlement: Entitlement::ReadView,
227
+ available: always,
228
+ parameters: |_| {
229
+ json!({
230
+ "type": "object",
231
+ "properties": { "panel": panel_property() }
232
+ })
233
+ },
234
+ call: |elem, ctx, args| Box::pin(elem.tool_get_view_config(ctx, args)),
235
+ },
236
+ Tool {
237
+ name: "set_view_config",
238
+ description: "Apply a partial configuration update to a panel. Only the fields provided \
239
+ are changed. Expression columns must be declared in `expressions` before \
240
+ being referenced in `columns`/`group_by`/etc. `columns` is POSITIONAL and \
241
+ every plugin reads the positions differently - call `list_plugins` for the \
242
+ active plugin's column roles and for what `group_by`/`split_by` draw in it. \
243
+ Returns the full configuration after the update is applied and rendered, \
244
+ plus `aggregation_changed_types` listing any column whose type CHANGED \
245
+ under aggregation (a `date` on the default `count` aggregate becomes an \
246
+ integer, so an axis bound to it is no longer a date axis).",
247
+ entitlement: Entitlement::ConfigureView,
248
+ available: always,
249
+ parameters: |schemas| {
250
+ config_parameters(
251
+ schemas,
252
+ "ViewerConfigUpdate",
253
+ "A partial ViewerConfigUpdate patch",
254
+ true,
255
+ &[],
256
+ )
257
+ },
258
+ call: |elem, ctx, args| Box::pin(elem.tool_set_view_config(ctx, args)),
259
+ },
260
+ Tool {
261
+ name: "list_plugins",
262
+ description: "List the visualization plugins registered with this viewer, each with the \
263
+ contract it declares: the ROLE each `columns` position fills (`columns[i]` \
264
+ fills `roles[i]`; extra columns repeat the last role), how many columns it \
265
+ requires, and what `group_by` and `split_by` draw in it. Use one of these \
266
+ exact names as the `plugin` field of set_view_config, and read its roles \
267
+ before writing `columns` - the same array means different things in \
268
+ different plugins.",
269
+ entitlement: Entitlement::ReadView,
270
+ available: always,
271
+ parameters: |_| json!({ "type": "object", "properties": {} }),
272
+ call: |elem, ctx, args| Box::pin(elem.tool_list_plugins(ctx, args)),
273
+ },
274
+ Tool {
275
+ name: "validate_expression",
276
+ description: "Validate an ExprTK expression against a panel's table without applying it. \
277
+ ExprTK examples: `\"Sales\" - \"Profit\"`, `if(\"Discount\" > 0.2, 'High', \
278
+ 'Low')`, `bucket(\"Order Date\", 'M')`. Column references are \
279
+ double-quoted; string literals are single-quoted. Use this to iterate on an \
280
+ expression before committing it via set_view_config's `expressions` field.",
281
+ entitlement: Entitlement::ReadView,
282
+ available: always,
283
+ parameters: |_| {
284
+ json!({
285
+ "type": "object",
286
+ "properties": {
287
+ "expression": {
288
+ "type": "string",
289
+ "description": "The ExprTK expression to validate"
290
+ },
291
+ "panel": panel_property()
292
+ },
293
+ "required": ["expression"]
294
+ })
295
+ },
296
+ call: |elem, ctx, args| Box::pin(elem.tool_validate_expression(ctx, args)),
297
+ },
298
+ Tool {
299
+ name: "get_style_schema",
300
+ description: "Get the active plugin's DECLARED style schema: the valid `plugin_config` \
301
+ keys, and - when `column` is given - the valid `columns_config` keys for \
302
+ that column. These are value-dependent (they vary by plugin, column type \
303
+ and current state), so call this before writing `plugin_config` or \
304
+ `columns_config` via set_view_config. Plugins that declare no schema return \
305
+ null sections - keys then pass through unvalidated.",
306
+ entitlement: Entitlement::ReadView,
307
+ available: always,
308
+ parameters: |_| {
309
+ json!({
310
+ "type": "object",
311
+ "properties": {
312
+ "column": {
313
+ "type": "string",
314
+ "description": "An active column name (see get_view_config `columns`)"
315
+ },
316
+ "panel": panel_property()
317
+ }
318
+ })
319
+ },
320
+ call: |elem, ctx, args| Box::pin(elem.tool_get_style_schema(ctx, args)),
321
+ },
322
+ Tool {
323
+ name: "list_panels",
324
+ description: "List the panels (independent side-by-side views) in this viewer's dashboard \
325
+ layout, and which one is active. View tools target the active panel unless \
326
+ given a `panel` argument.",
327
+ entitlement: Entitlement::ReadView,
328
+ available: always,
329
+ parameters: |_| json!({ "type": "object", "properties": {} }),
330
+ call: |elem, ctx, args| Box::pin(elem.tool_list_panels(ctx, args)),
331
+ },
332
+ Tool {
333
+ name: "add_panel",
334
+ description: "Add a new independent panel to the dashboard layout, configured with the \
335
+ given view config - same fields as set_view_config, plus `table`, which is \
336
+ REQUIRED (a new panel must bind a data table). `columns` is also REQUIRED: \
337
+ omitting it would display every column in the table. Compose the panel's \
338
+ COMPLETE configuration (plugin, columns, group_by, expressions, ...) in \
339
+ this one call. Returns the new panel's id and resulting configuration. To \
340
+ duplicate an existing panel, pass a config obtained from get_view_config. \
341
+ `columns` is POSITIONAL - see `list_plugins` for the chosen plugin's roles.",
342
+ entitlement: Entitlement::ManageLayout,
343
+ available: always,
344
+ parameters: |schemas| {
345
+ config_parameters(
346
+ schemas,
347
+ "ViewerConfigInitial",
348
+ "The new panel's ViewerConfigInitial (`table` and `columns` required)",
349
+ false,
350
+ &["table", "columns"],
351
+ )
352
+ },
353
+ call: |elem, ctx, args| Box::pin(elem.tool_add_panel(ctx, args)),
354
+ },
355
+ Tool {
356
+ name: "remove_panel",
357
+ description: "Remove a panel by id, disposing its view. Removing every panel leaves an \
358
+ empty viewer.",
359
+ entitlement: Entitlement::ManageLayout,
360
+ available: always,
361
+ parameters: |_| {
362
+ json!({
363
+ "type": "object",
364
+ "properties": {
365
+ "panel": {
366
+ "type": "string",
367
+ "description": "Panel id from `list_panels`"
368
+ }
369
+ },
370
+ "required": ["panel"]
371
+ })
372
+ },
373
+ call: |elem, ctx, args| Box::pin(elem.tool_remove_panel(ctx, args)),
374
+ },
375
+ Tool {
376
+ name: "activate_panel",
377
+ description: "Make a panel active: the target of the settings sidebar and of view tools \
378
+ called without a `panel` argument.",
379
+ entitlement: Entitlement::ManageLayout,
380
+ available: always,
381
+ parameters: |_| {
382
+ json!({
383
+ "type": "object",
384
+ "properties": {
385
+ "panel": {
386
+ "type": "string",
387
+ "description": "Panel id from `list_panels`"
388
+ }
389
+ },
390
+ "required": ["panel"]
391
+ })
392
+ },
393
+ call: |elem, ctx, args| Box::pin(elem.tool_activate_panel(ctx, args)),
394
+ },
395
+ Tool {
396
+ name: "search_docs",
397
+ description: "Search the reference documentation: Perspective features, configuration \
398
+ fields, expression syntax, plugin capabilities, and any host-supplied data \
399
+ definitions. Queries are keyword-matched - prefer concrete terms (e.g. \
400
+ `expressions bucket date`, not full sentences), and re-query with different \
401
+ keywords if the results look irrelevant.",
402
+ entitlement: Entitlement::ReadDocs,
403
+ available: always,
404
+ parameters: |_| {
405
+ json!({
406
+ "type": "object",
407
+ "properties": {
408
+ "query": {
409
+ "type": "string",
410
+ "description": "Keyword search query"
411
+ },
412
+ "limit": {
413
+ "type": "integer",
414
+ "description": "Maximum results (default 4)"
415
+ }
416
+ },
417
+ "required": ["query"]
418
+ })
419
+ },
420
+ call: |elem, ctx, args| Box::pin(elem.tool_search_docs(ctx, args)),
421
+ },
422
+ ];
423
+
424
+ fn granted(tool: &Tool, ctx: &ToolCtx) -> bool {
425
+ ctx.entitlements.contains(&tool.entitlement) && (tool.available)(ctx)
426
+ }
427
+
428
+ /// The tools advertised to the model for this turn.
429
+ pub fn tool_definitions(ctx: &ToolCtx) -> Vec<ToolDef> {
430
+ TOOLS
431
+ .iter()
432
+ .filter(|x| granted(x, ctx))
433
+ .map(|x| ToolDef {
434
+ kind: "function",
435
+ function: FunctionDef {
436
+ name: x.name,
437
+ description: x.description,
438
+ parameters: (x.parameters)(ctx.schemas()),
439
+ },
440
+ })
441
+ .collect()
442
+ }
443
+
444
+ /// Run one tool call. `arguments` is the model's JSON-encoded argument
445
+ /// string; all failures (including undecodable arguments and unknown or
446
+ /// withheld names — indistinguishable, so no capability leak) surface as
447
+ /// `ToolError` for the model to correct. The transcript/CustomEvent
448
+ /// `emit` happens HERE, once, for every tool.
449
+ pub async fn tool_dispatch(
450
+ elem: &PerspectiveViewerElement,
451
+ ctx: &ToolCtx,
452
+ name: &str,
453
+ arguments: &str,
454
+ ) -> Result<Value, ToolError> {
455
+ let tool = TOOLS
456
+ .iter()
457
+ .find(|x| x.name == name && granted(x, ctx))
458
+ .ok_or_else(|| ToolError(format!("Unknown tool `{name}`")))?;
459
+
460
+ let args: Value = if arguments.trim().is_empty() {
461
+ json!({})
462
+ } else {
463
+ serde_json::from_str(arguments)?
464
+ };
465
+
466
+ elem.agent_emit(name, &args);
467
+ let result = (tool.call)(elem, ctx, args).await;
468
+ if let Err(err) = &result {
469
+ elem.agent_emit_error(&format!("{err}"));
470
+ }
471
+
472
+ result
473
+ }
474
+
475
+ /// Tool failures are returned to the model as error tool-results so it can
476
+ /// self-correct (e.g. a rejected `ViewerConfigUpdate` patch).
477
+ #[derive(Debug, thiserror::Error)]
478
+ #[error("{0}")]
479
+ pub struct ToolError(String);
480
+
481
+ /// Columns whose type CHANGED under aggregation, reported alongside an
482
+ /// applied config.
483
+ fn retyped_columns(
484
+ session: &crate::session::Session,
485
+ config: &crate::config::ViewerConfig,
486
+ ) -> Vec<Value> {
487
+ let metadata = session.metadata();
488
+ config
489
+ .view_config
490
+ .columns
491
+ .iter()
492
+ .flatten()
493
+ .filter_map(|name| {
494
+ let view_type = metadata.get_column_view_type(name)?;
495
+ let table_type = metadata.get_column_table_type(name)?;
496
+ if view_type == table_type {
497
+ return None;
498
+ }
499
+
500
+ Some(json!({
501
+ "name": name,
502
+ "type": view_type,
503
+ "source_type": table_type,
504
+ "aggregate": config.view_config.aggregates.get(name),
505
+ }))
506
+ })
507
+ .collect()
508
+ }
509
+
510
+ /// Append a `search_docs` pointer to a config-application error when a docs
511
+ /// corpus is configured — a rejected config is the moment the model most
512
+ /// needs the reference, and self-correction errors are the one channel it
513
+ /// reliably reads (the preamble's docs step alone was not enough in the
514
+ /// field). No-op without docs, where the tool does not exist.
515
+ fn with_docs_hint(ctx: &ToolCtx, err: ToolError) -> ToolError {
516
+ if ctx.docs.is_some() {
517
+ ToolError(format!(
518
+ "{} If a field or value was rejected, its reference documentation is available - call \
519
+ `search_docs` with the failing field or function name.",
520
+ err.0
521
+ ))
522
+ } else {
523
+ err
524
+ }
525
+ }
526
+
527
+ impl From<ApiError> for ToolError {
528
+ fn from(err: ApiError) -> Self {
529
+ Self(format!("{err}"))
530
+ }
531
+ }
532
+
533
+ impl From<serde_json::Error> for ToolError {
534
+ fn from(err: serde_json::Error) -> Self {
535
+ Self(format!("{err}"))
536
+ }
537
+ }
538
+
539
+ impl From<JsValue> for ToolError {
540
+ fn from(err: JsValue) -> Self {
541
+ Self(
542
+ err.as_string()
543
+ .or_else(|| {
544
+ js_sys::Reflect::get(&err, &JsValue::from_str("message"))
545
+ .ok()
546
+ .and_then(|x| x.as_string())
547
+ })
548
+ .unwrap_or_else(|| format!("{err:?}")),
549
+ )
550
+ }
551
+ }
552
+
553
+ #[derive(Deserialize)]
554
+ struct PanelArgs {
555
+ panel: Option<String>,
556
+ }
557
+
558
+ #[derive(Deserialize)]
559
+ struct SetViewConfigArgs {
560
+ config: Value,
561
+ panel: Option<String>,
562
+ }
563
+
564
+ #[derive(Deserialize)]
565
+ struct ValidateExpressionArgs {
566
+ expression: String,
567
+ panel: Option<String>,
568
+ }
569
+
570
+ #[derive(Deserialize)]
571
+ struct AddPanelArgs {
572
+ config: Value,
573
+ }
574
+
575
+ #[derive(Deserialize)]
576
+ struct PanelNameArgs {
577
+ panel: String,
578
+ }
579
+
580
+ #[derive(Deserialize)]
581
+ struct SearchDocsArgs {
582
+ query: String,
583
+ limit: Option<usize>,
584
+ }
585
+
586
+ #[derive(Deserialize)]
587
+ struct StyleSchemaArgs {
588
+ column: Option<String>,
589
+ panel: Option<String>,
590
+ }
591
+
592
+ fn control_schema_entries(spec: &ControlSpec) -> Vec<(String, Value)> {
593
+ match spec {
594
+ ControlSpec::Enum {
595
+ key,
596
+ variants,
597
+ default,
598
+ } => vec![(
599
+ key.clone(),
600
+ json!({
601
+ "type": "string",
602
+ "enum": variants.iter().map(|x| x.value.clone()).collect::<Vec<_>>(),
603
+ "default": default
604
+ }),
605
+ )],
606
+ ControlSpec::Bool { key, default } => vec![(
607
+ key.clone(),
608
+ json!({ "type": "boolean", "default": default }),
609
+ )],
610
+ ControlSpec::Number {
611
+ key,
612
+ default,
613
+ min,
614
+ max,
615
+ ..
616
+ } => {
617
+ let mut node = json!({ "type": "number", "default": default });
618
+ if let Some(min) = min {
619
+ node["minimum"] = json!(min);
620
+ }
621
+
622
+ if let Some(max) = max {
623
+ node["maximum"] = json!(max);
624
+ }
625
+
626
+ vec![(key.clone(), node)]
627
+ },
628
+ ControlSpec::String { key, default, .. } => {
629
+ vec![(key.clone(), json!({ "type": "string", "default": default }))]
630
+ },
631
+ ControlSpec::Color { key, default } => vec![(
632
+ key.clone(),
633
+ json!({ "type": "string", "format": "color", "default": default }),
634
+ )],
635
+ ControlSpec::ColorRange {
636
+ key_pos,
637
+ key_neg,
638
+ default_pos,
639
+ default_neg,
640
+ ..
641
+ } => vec![
642
+ (
643
+ key_pos.clone(),
644
+ json!({ "type": "string", "format": "color", "default": default_pos }),
645
+ ),
646
+ (
647
+ key_neg.clone(),
648
+ json!({ "type": "string", "format": "color", "default": default_neg }),
649
+ ),
650
+ ],
651
+ ControlSpec::DatetimeFormat => vec![(
652
+ "date_format".to_owned(),
653
+ json!({ "description": "Datetime display format: a style preset or custom format fields" }),
654
+ )],
655
+ ControlSpec::StringFormat => vec![(
656
+ "format".to_owned(),
657
+ json!({ "description": "String display format" }),
658
+ )],
659
+ ControlSpec::NumberSeriesStyle { .. } => vec![
660
+ (
661
+ "chart_type".to_owned(),
662
+ json!({ "type": "string", "description": "Per-series glyph override" }),
663
+ ),
664
+ (
665
+ "stack".to_owned(),
666
+ json!({ "description": "Series stack group" }),
667
+ ),
668
+ ],
669
+ ControlSpec::Symbols { .. } => vec![(
670
+ "symbols".to_owned(),
671
+ json!({ "type": "object", "description": "Map of column values to symbol names" }),
672
+ )],
673
+ ControlSpec::NumberFormat => vec![(
674
+ "number_format".to_owned(),
675
+ json!({ "type": "object", "description": "Intl.NumberFormat-style options" }),
676
+ )],
677
+ ControlSpec::AggregateDepth => vec![(
678
+ "aggregate_depth".to_owned(),
679
+ json!({ "type": "integer", "description": "Group-by rollup depth override" }),
680
+ )],
681
+ }
682
+ }
683
+
684
+ fn schema_object(schema: &ColumnConfigSchema) -> Value {
685
+ let mut properties = serde_json::Map::new();
686
+ for spec in &schema.fields {
687
+ for (key, node) in control_schema_entries(spec) {
688
+ properties.insert(key, node);
689
+ }
690
+ }
691
+
692
+ json!({ "type": "object", "properties": properties })
693
+ }
694
+
695
+ const SEARCH_DOCS_LIMIT: usize = 4;
696
+
697
+ /// Tool bodies and their shared helpers — plain (non-bindgen) methods.
698
+ impl PerspectiveViewerElement {
699
+ /// Resolve a tool's panel target: a named panel, or the active panel
700
+ /// when `None`. Unknown names error with the current panel list so the
701
+ /// model can self-correct.
702
+ fn agent_panel(&self, target: Option<&str>) -> Result<Panel, ToolError> {
703
+ let id = target.map(|x| PanelId::from(x.to_owned()));
704
+ self.workspace
705
+ .panel_or_active(id.as_ref())
706
+ .ok_or_else(|| match target {
707
+ Some(name) => ToolError(format!(
708
+ "Unknown panel `{name}` - current panels: {:?}",
709
+ self.agent_panel_names()
710
+ )),
711
+ None => ToolError("No panel - the viewer has no data loaded yet".to_owned()),
712
+ })
713
+ }
714
+
715
+ fn agent_panel_names(&self) -> Vec<String> {
716
+ self.workspace
717
+ .panel_ids()
718
+ .iter()
719
+ .map(|x| x.as_str().to_owned())
720
+ .collect()
721
+ }
722
+
723
+ fn agent_active_name(&self) -> Option<String> {
724
+ self.workspace.active_id().map(|x| x.as_str().to_owned())
725
+ }
726
+
727
+ /// Record tool activity on the transcript (chat UI) and notify host
728
+ /// listeners (`perspective-agent-tool`).
729
+ fn agent_emit_error(&self, message: &str) {
730
+ self.presentation.agent.record_tool_error(message);
731
+ }
732
+
733
+ fn agent_emit(&self, name: &str, args: &Value) {
734
+ self.presentation.agent.record_tool(name, args);
735
+ let detail: JsValue = json!({ "name": name, "args": args }).to_string().into();
736
+ let event_init = web_sys::CustomEventInit::new();
737
+ event_init.set_bubbles(true);
738
+ event_init.set_detail(&detail);
739
+ if let Ok(event) =
740
+ web_sys::CustomEvent::new_with_event_init_dict("perspective-agent-tool", &event_init)
741
+ {
742
+ let _ = self.elem.dispatch_event(&event);
743
+ }
744
+ }
745
+
746
+ async fn tool_get_schema(&self, _ctx: &ToolCtx, args: Value) -> Result<Value, ToolError> {
747
+ let args: PanelArgs = serde_json::from_value(args)?;
748
+ let panel = self.agent_panel(args.panel.as_deref())?;
749
+ let table = panel
750
+ .session
751
+ .get_table()
752
+ .ok_or_else(|| ToolError("No table loaded".to_owned()))?;
753
+
754
+ let schema = table.schema().await.map_err(ApiError::from)?;
755
+ let size = table.size().await.map_err(ApiError::from)?;
756
+ Ok(json!({
757
+ "table": table.get_name(),
758
+ "columns": serde_json::to_value(&schema)?,
759
+ "num_rows": size,
760
+ }))
761
+ }
762
+
763
+ async fn tool_get_view_config(&self, _ctx: &ToolCtx, args: Value) -> Result<Value, ToolError> {
764
+ let args: PanelArgs = serde_json::from_value(args)?;
765
+ let panel = self.agent_panel(args.panel.as_deref())?;
766
+ let config = get_viewer_config(&panel.session, &panel.renderer, &self.presentation).await?;
767
+ Ok(serde_json::to_value(&config)?)
768
+ }
769
+
770
+ /// Applies via the public `restore()` with `suppress_errors: true`.
771
+ async fn tool_set_view_config(&self, ctx: &ToolCtx, args: Value) -> Result<Value, ToolError> {
772
+ let args: SetViewConfigArgs = serde_json::from_value(args)?;
773
+ let target = args.panel.as_deref();
774
+ let panel = self.agent_panel(target)?;
775
+ let snapshot =
776
+ get_viewer_config(&panel.session, &panel.renderer, &self.presentation).await?;
777
+
778
+ if let Err(err) = self.agent_restore(target, &args.config).await {
779
+ let rolled = self
780
+ .agent_restore(target, &serde_json::to_value(&snapshot)?)
781
+ .await;
782
+
783
+ let err = match rolled {
784
+ Ok(()) => err,
785
+ Err(rollback_err) => ToolError(format!(
786
+ "{err} (additionally, restoring the prior config failed: {rollback_err})"
787
+ )),
788
+ };
789
+
790
+ return Err(with_docs_hint(ctx, err));
791
+ }
792
+
793
+ let panel = self.agent_panel(target)?;
794
+ let config = get_viewer_config(&panel.session, &panel.renderer, &self.presentation).await?;
795
+ let mut result = serde_json::to_value(&config)?;
796
+ let retyped = retyped_columns(&panel.session, &config);
797
+ if !retyped.is_empty() {
798
+ result["aggregation_changed_types"] = json!(retyped);
799
+ }
800
+
801
+ Ok(result)
802
+ }
803
+
804
+ /// One public-API `restore()` call with `suppress_errors: true`.
805
+ async fn agent_restore(&self, target: Option<&str>, config: &Value) -> Result<(), ToolError> {
806
+ let options = js_sys::Object::new();
807
+ js_sys::Reflect::set(
808
+ &options,
809
+ &JsValue::from_str("suppress_errors"),
810
+ &JsValue::TRUE,
811
+ )?;
812
+
813
+ if let Some(name) = target {
814
+ js_sys::Reflect::set(
815
+ &options,
816
+ &JsValue::from_str("panel"),
817
+ &JsValue::from_str(name),
818
+ )?;
819
+ }
820
+
821
+ let js_config = js_sys::JSON::parse(&config.to_string())?;
822
+ let restored = self.restore(js_config.unchecked_into(), Some(options.unchecked_into()));
823
+ let promise: js_sys::Promise = JsValue::from(restored).unchecked_into();
824
+ wasm_bindgen_futures::JsFuture::from(promise).await?;
825
+ Ok(())
826
+ }
827
+
828
+ async fn tool_list_plugins(&self, _ctx: &ToolCtx, _args: Value) -> Result<Value, ToolError> {
829
+ let panel = self.agent_panel(None)?;
830
+ let plugins = panel
831
+ .renderer
832
+ .get_all_plugin_configs()
833
+ .iter()
834
+ .map(|config| {
835
+ let mut value = json!({
836
+ "name": config.name,
837
+ "category": config.category,
838
+ "columns": {
839
+ "roles": config.config_column_names,
840
+ "required": config.min_config_columns.unwrap_or(1),
841
+ "extra_columns": config.tail_column_role(),
842
+ },
843
+ "ordering": if config.connects_row_order {
844
+ json!("connects points in row order - `sort` by the \
845
+ X-axis column unless the table already \
846
+ arrives ordered")
847
+ } else {
848
+ Value::Null
849
+ },
850
+ "select_mode": match config.select_mode {
851
+ crate::config::ColumnSelectMode::Select => "select",
852
+ crate::config::ColumnSelectMode::Toggle => "toggle",
853
+ },
854
+ "supports_column_styles": config.can_render_column_styles,
855
+ });
856
+
857
+ if let Some(max) = config.max_columns {
858
+ value["columns"]["max"] = json!(max);
859
+ }
860
+
861
+ value["group_by"] = match &config.group_by_role {
862
+ Some(role) => json!(role),
863
+ None => json!("aggregates rows; no visual role"),
864
+ };
865
+
866
+ value["split_by"] = match &config.split_by_role {
867
+ Some(role) => json!(role),
868
+ None => json!("aggregates rows; no visual role"),
869
+ };
870
+
871
+ value
872
+ })
873
+ .collect::<Vec<_>>();
874
+
875
+ Ok(json!({
876
+ "plugins": plugins,
877
+ "columns_are_positional": "`columns[i]` fills the role at `roles[i]`; columns past the last named role repeat `extra_columns`.",
878
+ }))
879
+ }
880
+
881
+ async fn tool_validate_expression(
882
+ &self,
883
+ ctx: &ToolCtx,
884
+ args: Value,
885
+ ) -> Result<Value, ToolError> {
886
+ let args: ValidateExpressionArgs = serde_json::from_value(args)?;
887
+ let panel = self.agent_panel(args.panel.as_deref())?;
888
+ match validate_expr(&panel.session, &args.expression).await? {
889
+ None => Ok(json!({ "valid": true })),
890
+ Some(err) => {
891
+ let mut result = json!({
892
+ "valid": false,
893
+ "error": err.error_message,
894
+ "line": err.line,
895
+ "column": err.column,
896
+ });
897
+
898
+ if ctx.docs.is_some() {
899
+ result["hint"] = json!(
900
+ "ExprTK syntax and functions are documented - call `search_docs` with the \
901
+ function name or feature, e.g. `expressions bucket`."
902
+ );
903
+ }
904
+
905
+ Ok(result)
906
+ },
907
+ }
908
+ }
909
+
910
+ async fn tool_list_panels(&self, _ctx: &ToolCtx, _args: Value) -> Result<Value, ToolError> {
911
+ Ok(json!({
912
+ "panels": self.agent_panel_names(),
913
+ "active": self.agent_active_name(),
914
+ }))
915
+ }
916
+
917
+ async fn tool_add_panel(&self, ctx: &ToolCtx, args: Value) -> Result<Value, ToolError> {
918
+ let args: AddPanelArgs = serde_json::from_value(args)?;
919
+ if let Err(err) =
920
+ serde_json::from_value::<crate::config::ViewerConfigInitial>(args.config.clone())
921
+ {
922
+ let mut message = format!("Invalid panel config: {err}");
923
+ if message.contains("table")
924
+ && let Some(names) = self.agent_hosted_table_names().await
925
+ {
926
+ message.push_str(&format!(" - available tables: {names:?}"));
927
+ }
928
+
929
+ return Err(with_docs_hint(ctx, ToolError(message)));
930
+ }
931
+
932
+ match args.config.get("columns") {
933
+ Some(Value::Array(columns)) if !columns.is_empty() => {},
934
+ _ => {
935
+ return Err(ToolError(
936
+ "Invalid panel config: a new panel requires a non-empty `columns` list \
937
+ (omitting it would display every column in the table). Choose the columns - \
938
+ including any `expressions` names - this panel should display."
939
+ .to_owned(),
940
+ ));
941
+ },
942
+ }
943
+
944
+ let before = self.agent_panel_names();
945
+ let js_config = js_sys::JSON::parse(&args.config.to_string())?;
946
+ let added = self.addPanel(js_config.unchecked_into());
947
+ let result = wasm_bindgen_futures::JsFuture::from(js_sys::Promise::from(added)).await;
948
+ let id = match result {
949
+ Ok(id) => id
950
+ .as_string()
951
+ .ok_or_else(|| ToolError("addPanel returned no id".to_owned()))?,
952
+ Err(err) => {
953
+ for name in self.agent_panel_names() {
954
+ if !before.contains(&name) {
955
+ let removed = self.removePanel(name);
956
+ let _ =
957
+ wasm_bindgen_futures::JsFuture::from(js_sys::Promise::from(removed))
958
+ .await;
959
+ }
960
+ }
961
+
962
+ return Err(with_docs_hint(ctx, ToolError::from(err)));
963
+ },
964
+ };
965
+
966
+ let panel = self.agent_panel(Some(&id))?;
967
+ let config = get_viewer_config(&panel.session, &panel.renderer, &self.presentation).await?;
968
+ let mut result = json!({
969
+ "panel": id,
970
+ "config": serde_json::to_value(&config)?,
971
+ });
972
+
973
+ let retyped = retyped_columns(&panel.session, &config);
974
+ if !retyped.is_empty() {
975
+ result["aggregation_changed_types"] = json!(retyped);
976
+ }
977
+
978
+ Ok(result)
979
+ }
980
+
981
+ /// Best-effort list of the active panel's client's hosted table names,
982
+ /// for self-correction error messages.
983
+ async fn agent_hosted_table_names(&self) -> Option<Vec<String>> {
984
+ let panel = self.agent_panel(None).ok()?;
985
+ let client = panel.session.get_client()?;
986
+ client.get_hosted_table_names().await.ok()
987
+ }
988
+
989
+ async fn tool_remove_panel(&self, _ctx: &ToolCtx, args: Value) -> Result<Value, ToolError> {
990
+ let args: PanelNameArgs = serde_json::from_value(args)?;
991
+ self.agent_panel(Some(&args.panel))?;
992
+ let removed = self.removePanel(args.panel);
993
+ wasm_bindgen_futures::JsFuture::from(js_sys::Promise::from(removed)).await?;
994
+ Ok(json!({
995
+ "panels": self.agent_panel_names(),
996
+ "active": self.agent_active_name(),
997
+ }))
998
+ }
999
+
1000
+ async fn tool_activate_panel(&self, _ctx: &ToolCtx, args: Value) -> Result<Value, ToolError> {
1001
+ let args: PanelNameArgs = serde_json::from_value(args)?;
1002
+ self.agent_panel(Some(&args.panel))?;
1003
+ let activated = self.setActivePanel(args.panel);
1004
+ wasm_bindgen_futures::JsFuture::from(js_sys::Promise::from(activated)).await?;
1005
+ Ok(json!({ "active": self.agent_active_name() }))
1006
+ }
1007
+
1008
+ async fn tool_get_style_schema(&self, _ctx: &ToolCtx, args: Value) -> Result<Value, ToolError> {
1009
+ let args: StyleSchemaArgs = serde_json::from_value(args)?;
1010
+ let panel = self.agent_panel(args.panel.as_deref())?;
1011
+ let viewer_config =
1012
+ get_viewer_config(&panel.session, &panel.renderer, &self.presentation).await?;
1013
+
1014
+ let columns_config = serde_json::to_value(&viewer_config.columns_config)?;
1015
+ let plugin_schema = {
1016
+ let view_config = panel.session.get_view_config();
1017
+ get_plugin_config_schema(&panel.renderer, &view_config).ok()
1018
+ };
1019
+
1020
+ let column_schema = match &args.column {
1021
+ None => None,
1022
+ Some(column) => {
1023
+ let metadata = panel.session.metadata();
1024
+ if metadata.get_column_view_type(column).is_none() {
1025
+ let view_config = panel.session.get_view_config();
1026
+ let active = view_config
1027
+ .columns
1028
+ .iter()
1029
+ .flatten()
1030
+ .cloned()
1031
+ .collect::<Vec<_>>();
1032
+
1033
+ return Err(ToolError(format!(
1034
+ "Unknown or inactive column `{column}` - active columns: {active:?}"
1035
+ )));
1036
+ }
1037
+
1038
+ let current = columns_config.get(column).and_then(|x| x.as_object());
1039
+ let view_config = panel.session.get_view_config();
1040
+ Some(
1041
+ get_column_config_schema(
1042
+ &panel.renderer,
1043
+ &view_config,
1044
+ &metadata,
1045
+ column,
1046
+ current,
1047
+ None,
1048
+ )
1049
+ .ok(),
1050
+ )
1051
+ },
1052
+ };
1053
+
1054
+ let mut out = json!({
1055
+ "plugin": viewer_config.plugin,
1056
+ "plugin_config": plugin_schema.as_ref().map(schema_object),
1057
+ });
1058
+
1059
+ if let Some(column) = &args.column {
1060
+ out["column"] = json!(column);
1061
+ out["column_config"] = json!(column_schema.flatten().as_ref().map(schema_object));
1062
+ }
1063
+
1064
+ if out["plugin_config"].is_null() && out["column_config"].is_null() {
1065
+ out["note"] = json!(
1066
+ "The active plugin declares no style schema; `plugin_config` and `columns_config` \
1067
+ keys pass through unvalidated."
1068
+ );
1069
+ }
1070
+
1071
+ Ok(out)
1072
+ }
1073
+
1074
+ async fn tool_search_docs(&self, ctx: &ToolCtx, args: Value) -> Result<Value, ToolError> {
1075
+ let args: SearchDocsArgs = serde_json::from_value(args)?;
1076
+ let Some(bundle) = ctx.bundle.as_ref() else {
1077
+ return Ok(json!({
1078
+ "results": [],
1079
+ "hint": "No documentation is configured for this viewer",
1080
+ }));
1081
+ };
1082
+
1083
+ match bundle.as_ref() {
1084
+ Err(err) => Err(ToolError(format!("Docs failed to load: {err}"))),
1085
+ Ok(bundle) => {
1086
+ let results = bundle
1087
+ .index
1088
+ .search(&args.query, args.limit.unwrap_or(SEARCH_DOCS_LIMIT))
1089
+ .iter()
1090
+ .map(|chunk| {
1091
+ json!({
1092
+ "title": chunk.title,
1093
+ "path": chunk.path,
1094
+ "text": chunk.text,
1095
+ })
1096
+ })
1097
+ .collect::<Vec<_>>();
1098
+
1099
+ if results.is_empty() {
1100
+ Ok(json!({
1101
+ "results": [],
1102
+ "hint": "No matches - retry with different, more specific keywords",
1103
+ }))
1104
+ } else {
1105
+ Ok(json!({ "results": results }))
1106
+ }
1107
+ },
1108
+ }
1109
+ }
1110
+ }