diff --git a/Cargo.toml b/Cargo.toml index c68a520d40a..237a5423354 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -106,7 +106,7 @@ nsh-platform-tui = { path = "third-party/nsh/crates/nsh-platform-tui" } nsh-platform-windows = { path = "third-party/nsh/crates/nsh-platform-windows", default-features = false, features = ["js", "zlib", "crypto", "editor", "emoji-picker", "highlight", "math", "plot", "rich-editor", "term"] } nsh-platform-macos = { path = "third-party/nsh/crates/nsh-platform-macos", default-features = false, features = ["js", "zlib", "crypto", "editor", "emoji-picker", "highlight", "math", "plot", "rich-editor", "term"] } nsh-bundler = { path = "third-party/nsh/crates/nsh-bundler", default-features = false } -nsh-base = { path = "third-party/nsh/crates/nsh-base", features = ["math", "emoji-picker"] } +nsh-base = { path = "third-party/nsh/crates/nsh-base", features = ["math", "emoji-picker", "plot"] } maka-ui-build = { path = "tools/ui-build" } maka-agent-facts = { path = "plugins/agent/facts" } maka-agent-state = { path = "plugins/agent/state" } diff --git a/package-lock.json b/package-lock.json index dd19d4ea15c..dec46d7b710 100644 --- a/package-lock.json +++ b/package-lock.json @@ -735,6 +735,32 @@ "dev": true, "license": "MIT" }, + "node_modules/@json-render/core": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@json-render/core/-/core-0.22.0.tgz", + "integrity": "sha512-BIfJ2V3bLYETetBu2KWoxxRPU5cSAf77dDTBgqaV54kEAfyHta1psPJgNk0fDcB3FCrnCZ5KO/X41VDkrNSYgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "zod": "^4.3.6" + }, + "peerDependencies": { + "zod": "^4.0.0" + } + }, + "node_modules/@json-render/directives": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@json-render/directives/-/directives-0.22.0.tgz", + "integrity": "sha512-m0TSZfAUzhiHF1YX5Fkdfh/f0EwQu2p/oXEnIx1F9xdtV4UwIqqZ7UG+drNRAFPn8oMxRYdBq4tVXT5/Ksw59g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@json-render/core": "0.22.0" + }, + "peerDependencies": { + "zod": "^4.0.0" + } + }, "node_modules/@maka-agent/cli": { "resolved": "packages/cli", "link": true @@ -2189,9 +2215,12 @@ "version": "0.2.0", "license": "Apache-2.0", "devDependencies": { + "@json-render/core": "0.22.0", + "@json-render/directives": "0.22.0", "ajv": "8.20.0", "ajv-formats": "3.0.1", - "pele": "1.13.0" + "pele": "1.13.0", + "zod": "4.6.7" } } } diff --git a/plugins/agent/conversations/ui/Conversation.tsx b/plugins/agent/conversations/ui/Conversation.tsx index 6805b64280c..76a51b43534 100644 --- a/plugins/agent/conversations/ui/Conversation.tsx +++ b/plugins/agent/conversations/ui/Conversation.tsx @@ -3953,6 +3953,7 @@ export function Conversation(props: ConversationProps) { : undefined } messages={() => bot.forTurn(initial.turnId)} + sessionId={sessionId} openLink={props.openLink} userScroll={() => cancelNavigation()} /> diff --git a/plugins/skills/core/build.rs b/plugins/skills/core/build.rs index 411027ae5ff..179d108c758 100644 --- a/plugins/skills/core/build.rs +++ b/plugins/skills/core/build.rs @@ -199,6 +199,7 @@ fn main() { let mut source = String::from("const BUNDLED: &[Bundled] = &[\n"); for (id, relative, resources) in [ ("maka-cua", "platform/computer-use/skills/maka-cua", &[][..]), + ("maka-generative-ui", "skills/maka-generative-ui", &[][..]), ( "maka-plugin-authoring", "skills/maka-plugin-authoring", diff --git a/plugins/skills/core/tests/discovery.rs b/plugins/skills/core/tests/discovery.rs index e99bcc4fc9d..683d195dcdc 100644 --- a/plugins/skills/core/tests/discovery.rs +++ b/plugins/skills/core/tests/discovery.rs @@ -268,3 +268,29 @@ async fn same_named_skills_select_whole_directories_and_fall_back_after_removal( fs::remove_dir_all(path).unwrap(); } } + +#[tokio::test] +async fn bundled_generative_ui_skill_is_discoverable_and_loadable() { + let snapshot = maka_skills::scan_with_bundled(&[], &CancellationToken::new()) + .await + .unwrap(); + let preferences = Preferences::Available(BTreeMap::new()); + let host = HostCapabilities::default(); + let catalog = Catalog { + discovery: &snapshot, + preferences: &preferences, + host: &host, + }; + let loaded = catalog.load("maka-generative-ui").unwrap(); + assert_eq!( + loaded.skill.location.reference, + "bundled:maka-generative-ui" + ); + assert!( + loaded.skill.document.issues.is_empty(), + "{:?}", + loaded.skill.document.issues + ); + assert!(loaded.instructions.contains("```spec")); + assert!(loaded.instructions.contains("CLIENT-ONLY BOUNDARY")); +} diff --git a/products/maka/bundle/package.json b/products/maka/bundle/package.json index 078151c3c3f..01431cab3b0 100644 --- a/products/maka/bundle/package.json +++ b/products/maka/bundle/package.json @@ -10,8 +10,11 @@ "test": "node ../../../scripts/rust/product.mjs test" }, "devDependencies": { + "@json-render/core": "0.22.0", + "@json-render/directives": "0.22.0", "ajv": "8.20.0", "ajv-formats": "3.0.1", - "pele": "1.13.0" + "pele": "1.13.0", + "zod": "4.6.7" } } diff --git a/products/maka/cli/THIRD_PARTY_NOTICES.txt b/products/maka/cli/THIRD_PARTY_NOTICES.txt index 3dd4cd96475..7d19dd11f5d 100644 --- a/products/maka/cli/THIRD_PARTY_NOTICES.txt +++ b/products/maka/cli/THIRD_PARTY_NOTICES.txt @@ -2,6 +2,216 @@ Third-party notices for the Maka native distribution Dependency sources and alternative license texts are preserved below. Source URLs identify the exact distributed versions. +@json-render/core@0.22.0 (Apache-2.0) +@json-render/directives@0.22.0 (Apache-2.0) + +https://registry.npmjs.org/@json-render/core/-/core-0.22.0.tgz :: LICENSE +https://registry.npmjs.org/@json-render/directives/-/directives-0.22.0.tgz :: LICENSE + +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright 2025 Vercel Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +================================================================================ + adler2@2.0.1 (0BSD OR MIT OR Apache-2.0); authors declared by package: Jonas Schievink , oyvindln https://crates.io/api/v1/crates/adler2/2.0.1/download :: LICENSE-0BSD @@ -50891,6 +51101,34 @@ freely, subject to the following restrictions: ================================================================================ +zod@4.6.7 (MIT) + +https://registry.npmjs.org/zod/-/zod-4.6.7.tgz :: LICENSE + +MIT License + +Copyright (c) 2025 Colin McDonnell + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +================================================================================ + zopfli@0.8.4 (Apache-2.0) https://crates.io/api/v1/crates/zopfli/0.8.4/download :: COPYING diff --git a/products/maka/cli/notices.json b/products/maka/cli/notices.json index 3a7863e3caf..d2a7791d840 100644 --- a/products/maka/cli/notices.json +++ b/products/maka/cli/notices.json @@ -1483,8 +1483,11 @@ "cargo:zvariant_derive@5.15.1 registry+https://github.com/rust-lang/crates.io-index": "d2a1df59cf5f99d30e08bf03aa8a1a041b97fed9fce896bb5b5de40d616e434c", "cargo:zvariant_utils@4.2.0 registry+https://github.com/rust-lang/crates.io-index": "58641071a870aa97a0f38637179049eaf2ffaeeafbbb53e016c3d409411b9f88", "cargo:zvariant@5.15.1 registry+https://github.com/rust-lang/crates.io-index": "8606849c24d82d7d012980cba51a766abfe0307b48f936ec59e11b9a05b9c288", + "npm:@json-render/core@0.22.0 https://registry.npmjs.org/@json-render/core/-/core-0.22.0.tgz sha512-BIfJ2V3bLYETetBu2KWoxxRPU5cSAf77dDTBgqaV54kEAfyHta1psPJgNk0fDcB3FCrnCZ5KO/X41VDkrNSYgw==": "e1991c2b619e2e74d0e2d2d2ec278c08701e48cd8c7b097dac6ba0698c90dd12", + "npm:@json-render/directives@0.22.0 https://registry.npmjs.org/@json-render/directives/-/directives-0.22.0.tgz sha512-m0TSZfAUzhiHF1YX5Fkdfh/f0EwQu2p/oXEnIx1F9xdtV4UwIqqZ7UG+drNRAFPn8oMxRYdBq4tVXT5/Ksw59g==": "bf7e389ec4645fc87d4870eaf98b700386529b92a122c1cd8c50995537896f70", "npm:ajv@8.20.0 https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==": "e0b7c0627c7c460c99e8d7b8ac586e4c975ea2a2fae3859fdc94e67ec3aa69cc", - "npm:pele@1.13.0 https://registry.npmjs.org/pele/-/pele-1.13.0.tgz sha512-Ie8UKY9x00MsuR3jO6AzUwzK2Z6MIV3Tf4EDdITiuk8Uaz8K7g0R082WqsqNaSU8DdgkrRvZThzhit83mi/uiw==": "4fa75f9a04ed6fb14b9abd85c484443e73dfb84762d829f77406ee9a1b31189b" + "npm:pele@1.13.0 https://registry.npmjs.org/pele/-/pele-1.13.0.tgz sha512-Ie8UKY9x00MsuR3jO6AzUwzK2Z6MIV3Tf4EDdITiuk8Uaz8K7g0R082WqsqNaSU8DdgkrRvZThzhit83mi/uiw==": "4fa75f9a04ed6fb14b9abd85c484443e73dfb84762d829f77406ee9a1b31189b", + "npm:zod@4.6.7 https://registry.npmjs.org/zod/-/zod-4.6.7.tgz sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==": "8d405b2be44c8f37150acc9a7b5f1cf6103ddc26b043e56d34625b1c45d3a2ff" }, - "sha256": "6ffe25b937398d099f31cb0ef2c90bd6e4965dd2fe82acc7fdd62db959e09759" + "sha256": "4bc7a04a7f48315aeb355492b473f9bdaf1f7a452f219ce3fc379c4def6da1c0" } diff --git a/products/maka/terminal-shell/src/terminal/pixels/tests/rail.rs b/products/maka/terminal-shell/src/terminal/pixels/tests/rail.rs index 4625256b73a..4768886e714 100644 --- a/products/maka/terminal-shell/src/terminal/pixels/tests/rail.rs +++ b/products/maka/terminal-shell/src/terminal/pixels/tests/rail.rs @@ -70,7 +70,12 @@ pub(super) fn every_visible_turn_mark_can_be_clicked_with_cell_coordinates() { .join("tests/fixtures/turn-navigation.tsx"), minify: false, split: false, - ui_sets: vec![nsh_base::registry().into(), host.into()], + ui_sets: nsh_base::registry() + .sets() + .into_iter() + .map(Into::into) + .chain(std::iter::once(host.into())) + .collect(), asset_root_from: Some(std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../..")), ..Default::default() }) diff --git a/products/maka/terminal-shell/src/terminal/ui.rs b/products/maka/terminal-shell/src/terminal/ui.rs index c130a69aa34..2788c4d7017 100644 --- a/products/maka/terminal-shell/src/terminal/ui.rs +++ b/products/maka/terminal-shell/src/terminal/ui.rs @@ -470,7 +470,12 @@ mod tests { .join("tests/fixtures/components.tsx"), minify: false, split: false, - ui_sets: vec![nsh_base::registry().into(), (®istry).into()], + ui_sets: nsh_base::registry() + .sets() + .into_iter() + .map(Into::into) + .chain(std::iter::once((®istry).into())) + .collect(), asset_root_from: Some( std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../.."), ), diff --git a/products/maka/workbench/README.md b/products/maka/workbench/README.md index 88e2fd1b4b3..673bf3fb943 100644 --- a/products/maka/workbench/README.md +++ b/products/maka/workbench/README.md @@ -23,6 +23,8 @@ The shared NSH client runtime and Maka's default UI package. The native bindings Mermaid fences render locally with Pele. `TextView` retains ownership of Markdown; a language extension supplies the diagram view. Maka resolves SVG theme colors and converts labels to vector paths using local fonts on a blocking worker, then uses NSH's existing image loader without changing vendored NSH. Streamed edits are coalesced, source remains available for copying, and syntax errors show their source. Terminals show the code block. Diagram links, external images, and JavaScript callbacks are not enabled. Updates follow display frames and keep only the latest input while a conversion is in flight. Maka disables Pele’s source/output/edge quotas and adds no SVG byte or variable-depth caps; NSH’s existing image-loader constraints still apply. +`spec` fences render interactive Generative UI inline in conversation messages using `@json-render/core` and `@json-render/directives`. `SpecSession` compiles streamed RFC 6902 JSONL patches incrementally while preserving user-modified local state across `TextView` updates, and `SpecRenderer` mounts native NSH components and `nsh/base/plot` charts on both Desktop and Terminal. State bindings, directives, validation checks, watchers, and built-in actions remain strictly client-local and never send messages or RPC mutations back to the Agent or Host; authoring instructions ship in the bundled `maka-generative-ui` skill. + The crate bundles only the generic `app/Bootstrap.tsx` loader. A shell connects to the Host, admits itself with `host::admit`, creates `bindings::Bindings`, and supplies its viewport, environment and data directory through `configuration`. The bootstrap loads the complete UI selected by the deployment source's `ui: { path, id }`. The default product selects Maka's root, compiled into `app/App.js` in the same immutable package pipeline as other UI plugins. Custom roots own their navigation, layout and interactions; a missing root does not load Maka. The [terminal shell](../terminal-shell/README.md) and [desktop shell](../desktop-shell/README.md) use the same loader and native runtime. On a graphical desktop, the default root uses `app/chrome.ts` to make its top header the title bar and set the application menu. Side conversations keep their own model, draft and workspace. Conversation-scoped menu actions follow the pane containing native keyboard focus, just like its shortcuts. File and image requests carry their source session into the window's shared workspace preview; opening a resource from another conversation replaces that preview's tabs without changing either conversation. External URLs do not change the preview. The workspace view input lists mounted sessions instead of implicitly selecting the main conversation. diff --git a/products/maka/workbench/README.zh-CN.md b/products/maka/workbench/README.zh-CN.md index 91b9819f800..433afe73723 100644 --- a/products/maka/workbench/README.zh-CN.md +++ b/products/maka/workbench/README.zh-CN.md @@ -23,6 +23,8 @@ Mermaid 围栏代码块通过 Pele 在本地绘制。Markdown 仍由 `TextView` 负责,按语言扩展显示图表。Maka 落实 SVG 主题颜色,并在阻塞工作线程上使用本机字体把标签转为矢量路径,再交给 NSH 原有的图片加载器,不修改内置 NSH。流式修改会合并处理;可查看并复制源码,语法错误时保留源码。终端显示代码块。不启用图表链接、外部图片或 JavaScript 回调。更新按显示帧调度,转换进行中只保留最新输入。Maka 显式关闭 Pele 的源码、输出和边数限额,不额外设置 SVG 字节数或变量深度上限;NSH 图片加载器原有的约束仍然适用。 +`spec` 围栏代码块基于 `@json-render/core` 与 `@json-render/directives` 在对话消息流内嵌渲染交互式生成式 UI。`SpecSession` 增量编译流式 RFC 6902 JSONL 补丁,并在 `TextView` 更新期间保留用户在本地修改的状态;`SpecRenderer` 在桌面与终端两端直接挂载原生 NSH 组件与 `nsh/base/plot` 图表。状态绑定、动态指令、表单校验、监听器与内置动作全部在客户端本地执行,不会反向驱动 Agent 或发起 Host 业务变更;生成规范由内置 `maka-generative-ui` 技能提供。 + crate 只打包通用加载器 `app/Bootstrap.tsx`。shell 连接 Host,用 `host::admit` 接入,为该连接创建 `bindings::Bindings`,通过 `configuration` 提供视口、环境和数据目录。bootstrap 加载部署来源通过 `ui: { path, id }` 选择的完整 UI。默认产品选择 Maka 根界面,使用其他 UI 插件共用的不可变包构建流程,将它编译为包中的 `app/App.js`。自定义根界面拥有自己的导航、布局和交互;缺少根入口时不会加载 Maka。[终端 shell](../terminal-shell/README.zh-CN.md) 和[桌面 shell](../desktop-shell/README.zh-CN.md) 使用相同的加载器与原生运行时。在图形桌面上,默认根界面使用 `app/chrome.ts` 让顶部栏充当标题栏,并设置应用菜单。 侧边会话保留各自的模型、草稿和工作区。会话级菜单操作与快捷键一样,作用于原生键盘焦点所在的会话面板。文件和图片请求携带来源会话,交给窗口共用的工作区预览区;从另一会话打开资源时,会替换预览区的标签页,不会切换任何会话。外部 URL 不改变预览区。工作区视图输入明确列出已挂载会话,不再隐式选中主会话。 diff --git a/products/maka/workbench/app/BotReply.tsx b/products/maka/workbench/app/BotReply.tsx index 9d4f52ef482..ce3fda52bdd 100644 --- a/products/maka/workbench/app/BotReply.tsx +++ b/products/maka/workbench/app/BotReply.tsx @@ -17,18 +17,21 @@ * under the License. */ -import { View } from 'nsh'; +import { computed, Text, View } from 'nsh'; import { Button, TextView } from 'nsh/base'; -import { MermaidView, mermaidExtensions } from './MermaidView'; +import { MarkdownImageView } from './MarkdownImage'; +import { MermaidView } from './MermaidView'; +import { SpecView, markdownExtensions } from './SpecView'; import type { BotTranscriptMessage } from './botTranscript'; import { tr } from './locale'; -import { Text } from 'nsh'; import { Icon } from './Icon'; +import { splitSpecMarkdown } from './spec/session.ts'; import { C, bodyTextStyle, captionTextStyle } from './ui'; /** Messages the Bot sent with `SendUser`, shown as its reply in the Chat view. */ export function BotReply(props: { messages: () => BotTranscriptMessage[]; + sessionId?: string; openLink(target: string, restoreFocus: () => boolean): Promise; userScroll(): void; /** The worker whose finished run this reply reports, when the Turn was started by one. */ @@ -43,25 +46,84 @@ export function BotReply(props: { return ( String(message.sequence)}> - {(message) => ( - node.text ?? ''} />} - sourceId={`bot/${message().sequence}`} - selectable - contentStyle={{ rowGap: 16 }} - options={{ preserveLineBreaks: true }} - labels={{ table: tr('Markdown table'), copied: tr('Copied') }} - onScrollIntent={props.userScroll} - onLinkPress={(event) => void props.openLink(event.url, () => false)} - styles={{ - root: { ...bodyTextStyle, color: C.text }, - paragraph: bodyTextStyle, - code: { color: C.accentHover, backgroundColor: C.card }, - }} - /> - )} + {(message) => { + const segments = computed(() => splitSpecMarkdown(message().text)); + return ( + + seg.key}> + {(seg) => ( + { + const s = seg(); + return s.kind === 'markdown' ? s.text : ''; + })()} + extensions={markdownExtensions} + renderCustom={(node) => + node.name === 'spec' ? ( + node.text ?? ''} + sessionId={props.sessionId} + openLink={props.openLink} + /> + ) : ( + node.text ?? ''} /> + ) + } + sourceId={(() => { + const s = seg(); + return s.kind === 'markdown' && s.atStart + ? `bot/${message().sequence}` + : undefined; + })()} + selectable + contentStyle={{ rowGap: 16 }} + options={{ preserveLineBreaks: true }} + labels={{ table: tr('Markdown table'), copied: tr('Copied') }} + onScrollIntent={props.userScroll} + onLinkPress={(event) => { + const target = event.target; + if (!target || target.isLive()) { + void props.openLink(event.url, () => (target ? target.focus() : false)); + } + }} + renderImage={ + props.sessionId + ? (context) => ( + context.image} + sessionId={props.sessionId!} + linked={context.linked} + flow={context.flow} + lineHeight={context.lineHeight} + /> + ) + : undefined + } + styles={{ + root: { ...bodyTextStyle, color: C.text }, + paragraph: bodyTextStyle, + code: { color: C.accentHover, backgroundColor: C.card }, + }} + /> + } + > + { + const current = seg(); + return current.kind === 'spec' ? current.source : ''; + }} + sessionId={props.sessionId} + openLink={props.openLink} + /> + + )} + + + ); + }} + + + } + codeBlockActions={codeBlockActions()} + styles={codeBlockStyles(C.text, 12)} + /> + + + ); +} diff --git a/products/maka/workbench/app/locale-conversation.ts b/products/maka/workbench/app/locale-conversation.ts index 212f5149833..602459739a8 100644 --- a/products/maka/workbench/app/locale-conversation.ts +++ b/products/maka/workbench/app/locale-conversation.ts @@ -885,6 +885,38 @@ export const conversationMessages = { '图表无法显示:{reason}', '圖表無法顯示:{reason}', ], + 'Show spec source': ['Show spec source', '显示界面源码', '顯示介面原始碼'], + 'Hide spec source': ['Hide spec source', '隐藏界面源码', '隱藏介面原始碼'], + 'Rendering interface…': ['Rendering interface…', '正在生成界面…', '正在產生介面…'], + 'Interface unavailable: {reason}': [ + 'Interface unavailable: {reason}', + '界面无法显示:{reason}', + '介面無法顯示:{reason}', + ], + Confirm: ['Confirm', '确认', '確認'], + Previous: ['Previous', '上一页', '上一頁'], + Next: ['Next', '下一页', '下一頁'], + 'Page {page} of {total}': [ + 'Page {page} of {total}', + '第 {page} / {total} 页', + '第 {page} / {total} 頁', + ], + 'No items': ['No items', '暂无内容', '暫無內容'], + 'Select…': ['Select…', '请选择…', '請選擇…'], + '{count} selected': ['{count} selected', '已选 {count} 项', '已選 {count} 項'], + 'Decrease {label}': ['Decrease {label}', '减小 {label}', '減小 {label}'], + 'Decrease value': ['Decrease value', '减小数值', '減小數值'], + 'Increase {label}': ['Increase {label}', '增大 {label}', '增大 {label}'], + 'Increase value': ['Increase value', '增大数值', '增大數值'], + Slider: ['Slider', '滑块', '滑桿'], + Sparkline: ['Sparkline', '趋势微图', '趨勢微圖'], + 'Line chart': ['Line chart', '折线图', '折線圖'], + 'Area chart': ['Area chart', '面积图', '面積圖'], + 'Bar chart': ['Bar chart', '柱状图', '長條圖'], + 'Pie chart': ['Pie chart', '饼图', '圓餅圖'], + 'Scatter chart': ['Scatter chart', '散点图', '散佈圖'], + Heatmap: ['Heatmap', '热力图', '熱力圖'], + 'Slice {index}': ['Slice {index}', '分片 {index}', '分片 {index}'], 'Markdown table': ['Markdown table', 'Markdown 表格', 'Markdown 表格'], 'Match method': ['Match method', '匹配方式', '比對方式'], 'Matching files': ['Matching files', '匹配的文件', '符合的檔案'], diff --git a/products/maka/workbench/app/spec/catalog.ts b/products/maka/workbench/app/spec/catalog.ts new file mode 100644 index 00000000000..ab6da609d1c --- /dev/null +++ b/products/maka/workbench/app/spec/catalog.ts @@ -0,0 +1,1118 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { defineCatalog } from '@json-render/core'; +import { standardDirectives } from '@json-render/directives'; +import { z } from 'zod'; +import { specSchema } from './schema.ts'; + +const CheckItemSchema = z.object({ + type: z.string(), + message: z.string(), + args: z.record(z.string(), z.unknown()).optional(), +}); + +const ChecksSchema = z.array(CheckItemSchema).nullable().optional(); + +const SelectOptionSchema = z.union([ + z.string(), + z.object({ + label: z.string(), + value: z.string(), + description: z.string().nullable().optional(), + disabled: z.boolean().nullable().optional(), + }), +]); + +const ChartSeriesSchema = z.object({ + key: z.string(), + label: z.string().nullable().optional(), + color: z.string().nullable().optional(), +}); + +export const specComponentDefinitions = { + Card: { + props: z.object({ + title: z.string().nullable().optional(), + description: z.string().nullable().optional(), + subtitle: z.string().nullable().optional(), + padding: z.enum(['none', 'sm', 'md', 'lg']).nullable().optional(), + }), + slots: ['default', 'header', 'footer'], + description: + 'Elevated container grouping related content with optional title, description, and header/footer slots.', + example: { title: 'Overview', description: 'Live metrics and controls' }, + }, + + Text: { + props: z.object({ + text: z.string(), + variant: z.enum(['body', 'caption', 'muted', 'lead', 'code']).nullable().optional(), + color: z + .enum(['default', 'secondary', 'muted', 'accent', 'success', 'warning', 'danger']) + .nullable() + .optional(), + bold: z.boolean().nullable().optional(), + italic: z.boolean().nullable().optional(), + align: z.enum(['left', 'center', 'right']).nullable().optional(), + truncate: z.boolean().nullable().optional(), + }), + slots: [], + description: 'Body, caption, or inline monospace text.', + example: { text: 'Summary item' }, + }, + + Stack: { + props: z.object({ + direction: z.enum(['vertical', 'horizontal']).nullable().optional(), + gap: z + .union([z.enum(['none', 'xs', 'sm', 'md', 'lg']), z.number()]) + .nullable() + .optional(), + align: z.enum(['start', 'center', 'end', 'stretch', 'baseline']).nullable().optional(), + justify: z.enum(['start', 'center', 'end', 'between', 'around']).nullable().optional(), + wrap: z.boolean().nullable().optional(), + }), + slots: ['default'], + description: 'Vertical or horizontal flex stack with configurable gap and alignment.', + example: { direction: 'vertical', gap: 'md' }, + }, + + Grid: { + props: z.object({ + columns: z.number().nullable().optional(), + gap: z + .union([z.enum(['none', 'xs', 'sm', 'md', 'lg']), z.number()]) + .nullable() + .optional(), + }), + slots: ['default'], + description: + 'Multi-column CSS/Taffy grid container (defaults to 2 columns, collapses cleanly on narrow terminals).', + example: { columns: 3, gap: 'md' }, + }, + + Box: { + props: z.object({ + direction: z.enum(['column', 'row']).nullable().optional(), + gap: z.number().nullable().optional(), + padding: z.number().nullable().optional(), + paddingX: z.number().nullable().optional(), + paddingY: z.number().nullable().optional(), + alignItems: z.enum(['flex-start', 'center', 'flex-end', 'stretch']).nullable().optional(), + justifyContent: z + .enum(['flex-start', 'center', 'flex-end', 'space-between', 'space-around']) + .nullable() + .optional(), + bordered: z.boolean().nullable().optional(), + grow: z.number().nullable().optional(), + }), + slots: ['default'], + description: 'Low-level flex container with explicit padding, border, and flex grow.', + example: { direction: 'row', gap: 8, padding: 12, bordered: true }, + }, + + Spacer: { + props: z.object({ + size: z.number().nullable().optional(), + flex: z.boolean().nullable().optional(), + }), + slots: [], + description: 'Fixed spacing gap or flexible spring space inside a Stack or Box.', + example: { flex: true }, + }, + + Newline: { + props: z.object({ + count: z.number().nullable().optional(), + }), + slots: [], + description: 'Vertical line break spacing (count defaults to 1).', + example: { count: 1 }, + }, + + Separator: { + props: z.object({ + orientation: z.enum(['horizontal', 'vertical']).nullable().optional(), + }), + slots: [], + description: 'Thin visual rule separating sections or inline items.', + example: { orientation: 'horizontal' }, + }, + + Divider: { + props: z.object({ + title: z.string().nullable().optional(), + orientation: z.enum(['horizontal', 'vertical']).nullable().optional(), + }), + slots: [], + description: 'Section divider line with an optional centered title label.', + example: { title: 'Details' }, + }, + + Heading: { + props: z.object({ + text: z.string(), + level: z.enum(['h1', 'h2', 'h3', 'h4']).nullable().optional(), + }), + slots: [], + description: 'Section heading (h1-h4).', + example: { text: 'System Health', level: 'h2' }, + }, + + Markdown: { + props: z.object({ + content: z.string(), + }), + slots: [], + description: 'Rich formatted Markdown block (lists, emphasis, inline code, tables).', + example: { content: '**Note**: Values update automatically as you adjust sliders.' }, + }, + + CodeBlock: { + props: z.object({ + code: z.string(), + language: z.string().nullable().optional(), + }), + slots: [], + description: 'Syntax-highlighted code block with copy and wrap actions.', + example: { language: 'json', code: '{\n "port": 8080\n}' }, + }, + + Badge: { + props: z.object({ + text: z.string(), + variant: z + .enum([ + 'default', + 'secondary', + 'outline', + 'info', + 'success', + 'warning', + 'danger', + 'destructive', + ]) + .nullable() + .optional(), + }), + slots: [], + description: 'Compact status or category pill badge.', + example: { text: 'Healthy', variant: 'success' }, + }, + + StatusLine: { + props: z.object({ + label: z.string(), + status: z.enum(['info', 'success', 'warning', 'error', 'pending']).nullable().optional(), + detail: z.string().nullable().optional(), + }), + slots: [], + description: 'Single-line status indicator with semantic icon, label, and trailing detail.', + example: { label: 'Build pipeline', status: 'success', detail: '142 passed' }, + }, + + Alert: { + props: z.object({ + title: z.string(), + description: z.string().nullable().optional(), + type: z + .enum(['info', 'success', 'warning', 'error', 'default', 'destructive']) + .nullable() + .optional(), + }), + slots: [], + description: 'Prominent status banner with title and optional description.', + example: { + title: 'Quota warning', + description: '85% of monthly budget used.', + type: 'warning', + }, + }, + + Callout: { + props: z.object({ + type: z.enum(['info', 'tip', 'warning', 'important', 'caution']).nullable().optional(), + title: z.string().nullable().optional(), + content: z.string(), + }), + slots: [], + description: 'Highlighted callout block with left accent border for tips, notes, or warnings.', + example: { type: 'tip', title: 'Tip', content: 'Click any column header to sort rows.' }, + }, + + Metric: { + props: z.object({ + label: z.string(), + value: z.union([z.string(), z.number()]), + change: z.string().nullable().optional(), + trend: z.enum(['up', 'down', 'neutral']).nullable().optional(), + caption: z.string().nullable().optional(), + sparkline: z.array(z.number()).nullable().optional(), + }), + slots: [], + description: + 'KPI stat tile displaying a primary value, label, optional delta/trend badge, caption, and mini sparkline.', + example: { + label: 'P95 Latency', + value: '42 ms', + change: '-12%', + trend: 'down', + sparkline: [64, 58, 55, 49, 45, 42], + }, + }, + + KeyValue: { + props: z.object({ + label: z.string().nullable().optional(), + value: z.union([z.string(), z.number(), z.boolean()]).nullable().optional(), + items: z + .array( + z.object({ + key: z.string(), + value: z.union([z.string(), z.number(), z.boolean()]), + }), + ) + .nullable() + .optional(), + striped: z.boolean().nullable().optional(), + }), + slots: [], + description: 'Aligned key-value pair or list of key-value rows.', + example: { + items: [ + { key: 'Region', value: 'ap-northeast-1' }, + { key: 'Replicas', value: 3 }, + ], + }, + }, + + Table: { + props: z.object({ + columns: z.array( + z.union([ + z.string(), + z.object({ + key: z.string(), + header: z.string(), + align: z.enum(['left', 'center', 'right']).nullable().optional(), + width: z.number().nullable().optional(), + sortable: z.boolean().nullable().optional(), + }), + ]), + ), + rows: z.array(z.union([z.array(z.unknown()), z.record(z.string(), z.unknown())])), + caption: z.string().nullable().optional(), + sortable: z.boolean().nullable().optional(), + striped: z.boolean().nullable().optional(), + }), + slots: [], + description: + 'Structured data table supporting either string[] columns + 2D rows or {key, header}[] columns + object[] rows, with optional client-side column sorting.', + example: { + columns: ['Service', 'Status', 'Latency'], + rows: [ + ['api-gateway', 'Healthy', '18 ms'], + ['worker-pool', 'Healthy', '34 ms'], + ], + sortable: true, + }, + }, + + List: { + props: z.object({ + items: z.array(z.string()).nullable().optional(), + ordered: z.boolean().nullable().optional(), + divided: z.boolean().nullable().optional(), + }), + slots: ['default'], + description: 'Ordered or bulleted list from string items or ListItem children.', + example: { items: ['Verify schema', 'Run unit tests', 'Check bundle notices'], ordered: true }, + }, + + ListItem: { + props: z.object({ + title: z.string(), + subtitle: z.string().nullable().optional(), + trailing: z.string().nullable().optional(), + leading: z.string().nullable().optional(), + }), + slots: [], + events: ['press'], + description: + 'Structured row inside a List with title, optional subtitle, leading marker, and trailing value.', + example: { title: 'api-gateway', subtitle: '3 instances active', trailing: '99.98%' }, + }, + + Timeline: { + props: z.object({ + items: z.array( + z.object({ + title: z.string(), + description: z.string().nullable().optional(), + time: z.string().nullable().optional(), + status: z.enum(['completed', 'active', 'pending', 'error']).nullable().optional(), + }), + ), + }), + slots: [], + description: 'Vertical chronological timeline of milestones, stages, or events.', + example: { + items: [ + { title: 'Build completed', time: '10:01', status: 'completed' }, + { + title: 'Canary rollout', + description: '25% traffic shifted', + time: '10:04', + status: 'active', + }, + ], + }, + }, + + Progress: { + props: z.object({ + value: z.number(), + max: z.number().nullable().optional(), + label: z.string().nullable().optional(), + showValue: z.boolean().nullable().optional(), + color: z.enum(['accent', 'success', 'warning', 'danger']).nullable().optional(), + }), + slots: [], + description: + 'Horizontal progress bar (0 to max, default 100) with optional label and percentage.', + example: { label: 'Migration progress', value: 72, max: 100, showValue: true }, + }, + + ProgressBar: { + props: z.object({ + progress: z.number(), + total: z.number().nullable().optional(), + label: z.string().nullable().optional(), + color: z.enum(['accent', 'success', 'warning', 'danger']).nullable().optional(), + }), + slots: [], + description: + 'Alias progress indicator supporting fraction (0..1 when total omitted and progress <= 1) or progress/total.', + example: { label: 'Indexing', progress: 65, total: 100 }, + }, + + LineChart: { + props: z.object({ + title: z.string().nullable().optional(), + data: z.array(z.record(z.string(), z.unknown())), + xKey: z.string(), + series: z.array(ChartSeriesSchema), + height: z.number().nullable().optional(), + curve: z.enum(['linear', 'monotone', 'step']).nullable().optional(), + dots: z.boolean().nullable().optional(), + grid: z.boolean().nullable().optional(), + }), + slots: [], + description: + 'Native GPU/Braille multi-series line chart. Provide data rows, xKey for x-axis labels/numbers, and series [{ key, label?, color? }].', + example: { + title: 'Requests / sec', + xKey: 'time', + series: [{ key: 'rps', label: 'RPS' }], + data: [ + { time: '00:00', rps: 120 }, + { time: '06:00', rps: 180 }, + { time: '12:00', rps: 310 }, + ], + }, + }, + + AreaChart: { + props: z.object({ + title: z.string().nullable().optional(), + data: z.array(z.record(z.string(), z.unknown())), + xKey: z.string(), + series: z.array(ChartSeriesSchema), + height: z.number().nullable().optional(), + curve: z.enum(['linear', 'monotone', 'step']).nullable().optional(), + stacked: z.boolean().nullable().optional(), + }), + slots: [], + description: + 'Native GPU/Braille filled area chart supporting single, multi-series, or stacked series.', + example: { + title: 'Memory Usage (MB)', + xKey: 'step', + series: [{ key: 'heap', label: 'Heap' }], + data: [ + { step: 'T1', heap: 240 }, + { step: 'T2', heap: 310 }, + { step: 'T3', heap: 285 }, + ], + }, + }, + + BarChart: { + props: z.object({ + title: z.string().nullable().optional(), + data: z.array(z.record(z.string(), z.unknown())), + xKey: z.string().nullable().optional(), + series: z.array(ChartSeriesSchema).nullable().optional(), + stacked: z.boolean().nullable().optional(), + height: z.number().nullable().optional(), + showValues: z.boolean().nullable().optional(), + }), + slots: [], + description: + 'Native bar chart. Accepts either simple [{ label, value, color? }] data or multi-series data with xKey and series [{ key, label?, color? }].', + example: { + title: 'Latency by Tier (ms)', + data: [ + { label: 'Cache', value: 4 }, + { label: 'DB', value: 19 }, + { label: 'Search', value: 37 }, + ], + }, + }, + + PieChart: { + props: z.object({ + title: z.string().nullable().optional(), + data: z.array( + z.object({ + label: z.string(), + value: z.number(), + color: z.string().nullable().optional(), + }), + ), + donut: z.boolean().nullable().optional(), + height: z.number().nullable().optional(), + }), + slots: [], + description: 'Native pie or donut chart with automatic legend and percentage breakdown.', + example: { + title: 'Token Breakdown', + donut: true, + data: [ + { label: 'Input', value: 68 }, + { label: 'Cached', value: 22 }, + { label: 'Output', value: 10 }, + ], + }, + }, + + ScatterChart: { + props: z.object({ + title: z.string().nullable().optional(), + data: z.array(z.record(z.string(), z.unknown())), + xKey: z.string(), + yKey: z.string(), + labelKey: z.string().nullable().optional(), + sizeKey: z.string().nullable().optional(), + height: z.number().nullable().optional(), + }), + slots: [], + description: '2D scatter or bubble plot for numeric xKey and yKey coordinates.', + example: { + title: 'Cost vs Accuracy', + xKey: 'cost', + yKey: 'score', + data: [ + { cost: 1.2, score: 84 }, + { cost: 3.5, score: 92 }, + { cost: 6.0, score: 95 }, + ], + }, + }, + + Heatmap: { + props: z.object({ + title: z.string().nullable().optional(), + xLabels: z.array(z.string()), + yLabels: z.array(z.string()), + values: z.array(z.array(z.number())), + height: z.number().nullable().optional(), + }), + slots: [], + description: '2D matrix heatmap where values[row][col] maps to yLabels[row] and xLabels[col].', + example: { + title: 'Error Rate (%)', + xLabels: ['Mon', 'Tue', 'Wed'], + yLabels: ['API', 'Worker'], + values: [ + [0.1, 0.2, 0.1], + [0.4, 1.2, 0.3], + ], + }, + }, + + Sparkline: { + props: z.object({ + data: z.array(z.number()), + width: z.number().nullable().optional(), + height: z.number().nullable().optional(), + color: z.enum(['accent', 'success', 'warning', 'danger', 'muted']).nullable().optional(), + label: z.string().nullable().optional(), + }), + slots: [], + description: 'Compact inline trend sparkline for an array of numbers.', + example: { data: [12, 15, 13, 19, 24, 22, 29], color: 'accent' }, + }, + + Tabs: { + props: z.object({ + tabs: z.array( + z.object({ + label: z.string(), + value: z.string(), + content: z.string().nullable().optional(), + }), + ), + defaultValue: z.string().nullable().optional(), + value: z.string().nullable().optional(), + }), + slots: ['default'], + events: ['change'], + description: + 'Tabbed navigation container. Bind value with { "$bindState": "/tab" } and place TabContent children (or named slots matching tab value, or inline tab.content).', + example: { + defaultValue: 'summary', + tabs: [ + { label: 'Summary', value: 'summary' }, + { label: 'Logs', value: 'logs' }, + ], + }, + }, + + TabContent: { + props: z.object({ + value: z.string(), + }), + slots: ['default'], + description: 'Content panel inside Tabs shown only when its value matches the active tab.', + example: { value: 'summary' }, + }, + + Accordion: { + props: z.object({ + items: z.array( + z.object({ + title: z.string(), + content: z.string(), + value: z.string().nullable().optional(), + }), + ), + type: z.enum(['single', 'multiple']).nullable().optional(), + defaultOpen: z.array(z.number()).nullable().optional(), + }), + slots: [], + description: 'Expandable accordion sections with title and content.', + example: { + type: 'single', + items: [ + { title: 'Root cause', content: 'Connection pool saturated during batch import.' }, + { title: 'Mitigation', content: 'Cap batch concurrency at 8 workers.' }, + ], + }, + }, + + Collapsible: { + props: z.object({ + title: z.string(), + defaultOpen: z.boolean().nullable().optional(), + open: z.boolean().nullable().optional(), + }), + slots: ['default'], + events: ['change'], + description: 'Disclosure container that folds and unfolds its child elements.', + example: { title: 'Advanced options', defaultOpen: false }, + }, + + Dialog: { + props: z.object({ + title: z.string(), + description: z.string().nullable().optional(), + openPath: z.string(), + }), + slots: ['default'], + description: + 'Modal dialog controlled by boolean state at openPath (JSON Pointer, e.g. "/dialogOpen"). Open via a Button setting openPath to true.', + example: { title: 'Inspect Payload', description: 'Raw event fields', openPath: '/dialogOpen' }, + }, + + Drawer: { + props: z.object({ + title: z.string(), + description: z.string().nullable().optional(), + openPath: z.string(), + }), + slots: ['default'], + description: + 'Sheet overlay controlled by boolean state at openPath (JSON Pointer, e.g. "/drawerOpen").', + example: { title: 'Filter Details', openPath: '/drawerOpen' }, + }, + + Popover: { + props: z.object({ + trigger: z.string(), + content: z.string().nullable().optional(), + }), + slots: ['default'], + description: 'Anchored popover button showing text content or child elements when pressed.', + example: { + trigger: 'Explain formula', + content: 'Weighted harmonic mean across active shards.', + }, + }, + + Tooltip: { + props: z.object({ + content: z.string(), + text: z.string().nullable().optional(), + }), + slots: ['default'], + description: 'Hover/focus hint attached to a text label or child element.', + example: { text: 'SLO 99.9%', content: 'Rolling 30-day availability target' }, + }, + + Carousel: { + props: z.object({ + items: z + .array( + z.object({ + title: z.string().nullable().optional(), + description: z.string().nullable().optional(), + }), + ) + .nullable() + .optional(), + }), + slots: ['default'], + description: + 'Paged slide switcher stepping through items or child elements with Previous/Next controls.', + example: { + items: [ + { title: 'Step 1', description: 'Prepare configuration' }, + { title: 'Step 2', description: 'Verify dry-run output' }, + ], + }, + }, + + Image: { + props: z.object({ + src: z.string().nullable().optional(), + alt: z.string(), + width: z.number().nullable().optional(), + height: z.number().nullable().optional(), + }), + slots: [], + description: 'Image with alt label and graceful fallback frame when offline or in terminal.', + example: { alt: 'Architecture diagram', width: 320, height: 180 }, + }, + + Avatar: { + props: z.object({ + name: z.string(), + src: z.string().nullable().optional(), + size: z.enum(['sm', 'md', 'lg']).nullable().optional(), + }), + slots: [], + description: 'Initials badge (or image) representing a person or service.', + example: { name: 'Ada Lovelace', size: 'md' }, + }, + + Skeleton: { + props: z.object({ + lines: z.number().nullable().optional(), + height: z.number().nullable().optional(), + }), + slots: [], + description: 'Placeholder block indicating pending layout regions.', + example: { lines: 3 }, + }, + + Spinner: { + props: z.object({ + label: z.string().nullable().optional(), + }), + slots: [], + description: 'Compact activity indicator with optional label.', + example: { label: 'Computing projection…' }, + }, + + EmptyState: { + props: z.object({ + title: z.string(), + description: z.string().nullable().optional(), + }), + slots: ['default'], + description: + 'Centered empty-state placeholder with title, optional description, and optional action children.', + example: { title: 'No matching records', description: 'Try clearing active filters.' }, + }, + + Input: { + props: z.object({ + label: z.string().nullable().optional(), + value: z.string().nullable().optional(), + placeholder: z.string().nullable().optional(), + type: z.enum(['text', 'email', 'password', 'number', 'url', 'search']).nullable().optional(), + helperText: z.string().nullable().optional(), + disabled: z.boolean().nullable().optional(), + checks: ChecksSchema, + validateOn: z.enum(['change', 'blur', 'submit']).nullable().optional(), + }), + slots: [], + events: ['change', 'blur', 'submit'], + description: + 'Single-line text input. Bind value via { "$bindState": "/path" } or { "$bindItem": "field" }.', + example: { label: 'Filter host', placeholder: 'api-prod-*', value: '' }, + }, + + TextInput: { + props: z.object({ + label: z.string().nullable().optional(), + value: z.string().nullable().optional(), + placeholder: z.string().nullable().optional(), + masked: z.boolean().nullable().optional(), + disabled: z.boolean().nullable().optional(), + checks: ChecksSchema, + validateOn: z.enum(['change', 'blur', 'submit']).nullable().optional(), + }), + slots: [], + events: ['change', 'blur', 'submit'], + description: 'Single-line text field alias compatible with terminal/ink specs.', + example: { label: 'Query', placeholder: 'status:open' }, + }, + + Textarea: { + props: z.object({ + label: z.string().nullable().optional(), + value: z.string().nullable().optional(), + placeholder: z.string().nullable().optional(), + rows: z.number().nullable().optional(), + helperText: z.string().nullable().optional(), + disabled: z.boolean().nullable().optional(), + checks: ChecksSchema, + validateOn: z.enum(['change', 'blur', 'submit']).nullable().optional(), + }), + slots: [], + events: ['change', 'blur'], + description: 'Multi-line text editor bound via { "$bindState": "/path" }.', + example: { label: 'Notes', placeholder: 'Enter observations…', rows: 3 }, + }, + + NumberInput: { + props: z.object({ + label: z.string().nullable().optional(), + value: z.number().nullable().optional(), + min: z.number().nullable().optional(), + max: z.number().nullable().optional(), + step: z.number().nullable().optional(), + disabled: z.boolean().nullable().optional(), + checks: ChecksSchema, + }), + slots: [], + events: ['change'], + description: 'Numeric field with decrement/increment buttons and min/max/step clamping.', + example: { label: 'Worker count', value: 4, min: 1, max: 32, step: 1 }, + }, + + Select: { + props: z.object({ + label: z.string().nullable().optional(), + value: z.string().nullable().optional(), + options: z.array(SelectOptionSchema), + placeholder: z.string().nullable().optional(), + helperText: z.string().nullable().optional(), + disabled: z.boolean().nullable().optional(), + checks: ChecksSchema, + }), + slots: [], + events: ['change'], + description: + 'Dropdown selector accepting string[] or { label, value, description? }[] options. Bind value with { "$bindState": "/path" }.', + example: { + label: 'Environment', + options: ['production', 'staging', 'development'], + value: 'production', + }, + }, + + MultiSelect: { + props: z.object({ + label: z.string().nullable().optional(), + value: z.array(z.string()).nullable().optional(), + options: z.array(SelectOptionSchema), + disabled: z.boolean().nullable().optional(), + checks: ChecksSchema, + }), + slots: [], + events: ['change'], + description: 'Multi-option selector toggling values inside a bound string[] state array.', + example: { + label: 'Regions', + options: ['us-east', 'eu-west', 'ap-northeast'], + value: ['us-east'], + }, + }, + + Checkbox: { + props: z.object({ + label: z.string(), + description: z.string().nullable().optional(), + checked: z.boolean().nullable().optional(), + disabled: z.boolean().nullable().optional(), + checks: ChecksSchema, + }), + slots: [], + events: ['change'], + description: + 'Boolean checkbox control. Bind checked with { "$bindState": "/path" } or { "$bindItem": "done" }.', + example: { label: 'Include archived records', checked: false }, + }, + + Radio: { + props: z.object({ + label: z.string().nullable().optional(), + value: z.string().nullable().optional(), + options: z.array(SelectOptionSchema), + direction: z.enum(['vertical', 'horizontal']).nullable().optional(), + disabled: z.boolean().nullable().optional(), + checks: ChecksSchema, + }), + slots: [], + events: ['change'], + description: 'Exclusive radio group bound via { "$bindState": "/path" }.', + example: { + label: 'Billing cycle', + options: [ + { label: 'Monthly', value: 'monthly' }, + { label: 'Annual (save 20%)', value: 'annual' }, + ], + value: 'monthly', + }, + }, + + Switch: { + props: z.object({ + label: z.string(), + description: z.string().nullable().optional(), + checked: z.boolean().nullable().optional(), + disabled: z.boolean().nullable().optional(), + }), + slots: [], + events: ['change'], + description: 'Immediate on/off toggle row. Bind checked with { "$bindState": "/path" }.', + example: { label: 'Auto-refresh preview', checked: true }, + }, + + Slider: { + props: z.object({ + label: z.string().nullable().optional(), + value: z.number().nullable().optional(), + min: z.number().nullable().optional(), + max: z.number().nullable().optional(), + step: z.number().nullable().optional(), + unit: z.string().nullable().optional(), + disabled: z.boolean().nullable().optional(), + }), + slots: [], + events: ['change'], + description: + 'Interactive range slider with live value readout. Bind value with { "$bindState": "/path" }.', + example: { label: 'Concurrency', min: 1, max: 64, step: 1, value: 16 }, + }, + + Button: { + props: z.object({ + label: z.string(), + variant: z.enum(['primary', 'secondary', 'outline', 'ghost', 'danger']).nullable().optional(), + size: z.enum(['sm', 'md']).nullable().optional(), + disabled: z.boolean().nullable().optional(), + }), + slots: [], + events: ['press'], + description: + 'Action button triggering client actions on press (setState, pushState, resetState, validateForm, copyText).', + example: { label: 'Reset defaults', variant: 'secondary' }, + }, + + ButtonGroup: { + props: z.object({ + buttons: z + .array( + z.object({ + label: z.string(), + value: z.string(), + disabled: z.boolean().nullable().optional(), + }), + ) + .nullable() + .optional(), + selected: z.string().nullable().optional(), + }), + slots: ['default'], + events: ['change'], + description: + 'Segmented button row either from buttons[] bound via selected or from Button children.', + example: { + selected: '7d', + buttons: [ + { label: '24h', value: '24h' }, + { label: '7d', value: '7d' }, + { label: '30d', value: '30d' }, + ], + }, + }, + + Toggle: { + props: z.object({ + label: z.string(), + pressed: z.boolean().nullable().optional(), + disabled: z.boolean().nullable().optional(), + }), + slots: [], + events: ['change'], + description: 'Two-state pressed toggle button. Bind pressed with { "$bindState": "/path" }.', + example: { label: 'Show outliers', pressed: false }, + }, + + ToggleGroup: { + props: z.object({ + items: z.array( + z.object({ + label: z.string(), + value: z.string(), + }), + ), + type: z.enum(['single', 'multiple']).nullable().optional(), + value: z + .union([z.string(), z.array(z.string())]) + .nullable() + .optional(), + }), + slots: [], + events: ['change'], + description: + 'Single or multi-value segmented toggle group. Bind value with { "$bindState": "/path" }.', + example: { + type: 'single', + value: 'table', + items: [ + { label: 'Table', value: 'table' }, + { label: 'Chart', value: 'chart' }, + ], + }, + }, + + DropdownMenu: { + props: z.object({ + label: z.string(), + value: z.string().nullable().optional(), + items: z.array( + z.object({ + label: z.string(), + value: z.string(), + disabled: z.boolean().nullable().optional(), + }), + ), + }), + slots: [], + events: ['select'], + description: + 'Popover action menu writing the chosen item value to bound value or firing on.select.', + example: { + label: 'Preset', + items: [ + { label: 'Conservative', value: 'conservative' }, + { label: 'Balanced', value: 'balanced' }, + { label: 'Aggressive', value: 'aggressive' }, + ], + }, + }, + + Pagination: { + props: z.object({ + page: z.number().nullable().optional(), + totalPages: z.number(), + }), + slots: [], + events: ['change'], + description: 'Page navigator (1..totalPages). Bind page with { "$bindState": "/page" }.', + example: { page: 1, totalPages: 5 }, + }, + + ConfirmInput: { + props: z.object({ + message: z.string(), + confirmLabel: z.string().nullable().optional(), + cancelLabel: z.string().nullable().optional(), + value: z.boolean().nullable().optional(), + }), + slots: [], + events: ['confirm', 'cancel'], + description: + 'Inline Yes/No confirmation prompt updating bound boolean value and firing confirm/cancel events.', + example: { + message: 'Include experimental benchmarks?', + confirmLabel: 'Yes', + cancelLabel: 'No', + }, + }, + + Link: { + props: z.object({ + label: z.string(), + href: z.string(), + }), + slots: [], + events: ['press'], + description: + 'External link opened through the workbench link handler (or firing client press actions).', + example: { label: 'Apache License 2.0', href: 'https://www.apache.org/licenses/LICENSE-2.0' }, + }, +}; + +export const specCatalog = defineCatalog(specSchema, { + components: specComponentDefinitions, +}); + +export function buildSpecCatalogPrompt(): string { + const raw = specCatalog.prompt({ + system: + 'Maka natively renders inline ```spec code blocks inside conversation messages using the catalog and rules below.', + mode: 'inline', + directives: standardDirectives, + customRules: [ + 'Prefer interactive client state ($bindState, Slider, Tabs, Select, Switch, Table sorting) over static prose when the user is comparing trade-offs, exploring parameters, inspecting structured metrics, or filling a calculator/checklist.', + 'Do NOT emit ```spec blocks for plain text answers, simple 2-row tables, or flowcharts/sequence diagrams (use ```mermaid for architecture/sequence diagrams, and ```spec for data charts, dashboards, metrics, and interactive UI).', + ], + }); + + return raw + .replace( + 'Example (cascading select \u2014 country changes trigger city loading):', + 'Example (cascading select \u2014 country changes reset dependent city selection):', + ) + .replace( + '{"action":"loadCities","params":{"country":{"$state":"/form/country"}}}', + '{"action":"setState","params":{"statePath":"/form/city","value":""}}', + ) + .replace( + '(loading data, resetting dependent fields, computing derived values)', + '(resetting dependent fields, clearing stale selections, updating client state)', + ); +} diff --git a/products/maka/workbench/app/spec/charts.tsx b/products/maka/workbench/app/spec/charts.tsx new file mode 100644 index 00000000000..1c5f16ba954 --- /dev/null +++ b/products/maka/workbench/app/spec/charts.tsx @@ -0,0 +1,653 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { platform, Text, View, type JSX } from 'nsh'; +import { + Area, + Bar, + Cell, + Dot, + Grid as PlotGrid, + Line, + Pie, + Plot, + PlotAxis, + type Curve, +} from 'nsh/base/plot'; +import { tr } from '../locale'; +import { C, captionTextStyle, secondaryTextStyle } from '../ui'; +import type { SpecElementRenderContext } from './runtime.tsx'; + +type SeriesItem = { + key: string; + label: string; + color: string; +}; + +function chartPalette(): string[] { + return [C.accent, C.success, C.warning, C.danger, C.accentHover, C.secondary]; +} + +function resolveSemanticColor(raw: unknown, fallback: string): string { + if (typeof raw !== 'string' || !raw) return fallback; + switch (raw) { + case 'accent': + return C.accent; + case 'success': + return C.success; + case 'warning': + return C.warning; + case 'danger': + return C.danger; + case 'muted': + return C.muted; + case 'secondary': + return C.secondary; + default: + return raw; + } +} + +function resolveCurve(raw: unknown): Curve { + if (raw === 'linear') return 'linear'; + if (raw === 'step') return 'stepAfter'; + return 'natural'; +} + +function str(raw: unknown, fallback = ''): string { + return typeof raw === 'string' ? raw : fallback; +} + +function parseSeries(raw: unknown): SeriesItem[] { + const palette = chartPalette(); + if (!Array.isArray(raw)) return []; + const out: SeriesItem[] = []; + for (let i = 0; i < raw.length; i++) { + const item = raw[i]; + if (typeof item === 'string' && item) { + out.push({ key: item, label: item, color: palette[i % palette.length]! }); + continue; + } + if (!item || typeof item !== 'object') continue; + const obj = item as Record; + const key = typeof obj.key === 'string' ? obj.key : ''; + if (!key) continue; + const label = typeof obj.label === 'string' && obj.label ? obj.label : key; + const color = resolveSemanticColor(obj.color, palette[i % palette.length]!); + out.push({ key, label, color }); + } + return out; +} + +function parseRows(raw: unknown): Record[] { + if (!Array.isArray(raw)) return []; + return raw.filter( + (item): item is Record => + item !== null && typeof item === 'object' && !Array.isArray(item), + ); +} + +function defaultChartHeight(raw: unknown, fallback = 180): number { + const requested = typeof raw === 'number' && Number.isFinite(raw) ? raw : fallback; + const clamped = Math.max(80, Math.min(480, Math.round(requested))); + return platform.idiom === 'terminal' ? Math.max(64, Math.min(160, clamped)) : clamped; +} + +function LegendRow(props: { items: () => readonly { label: string; color: string }[] }) { + return ( + 1}> + + `${item.label}:${item.color}`}> + {(item) => ( + + + {item().label} + + )} + + + + ); +} + +function ChartFrame(props: { + title: () => string; + empty: () => boolean; + legend?: () => readonly { label: string; color: string }[]; + children: JSX.Element; +}) { + return ( + + + + {props.title()} + + + {props.legend ? : null} + {tr('No items')}} + > + {props.children} + + + ); +} + +export function MiniSparkline(props: { + values: () => number[]; + width?: () => number; + height?: () => number; + color?: () => string; + label?: () => string; +}) { + const rows = () => props.values().map((v, idx) => ({ idx, value: v })); + const w = () => props.width?.() ?? (platform.idiom === 'terminal' ? 96 : 112); + const h = () => props.height?.() ?? (platform.idiom === 'terminal' ? 32 : 36); + const stroke = () => props.color?.() ?? C.accent; + return ( + 0}> + + + + + ); +} + +export function renderSparkline(ctx: SpecElementRenderContext): JSX.Element { + const p = ctx.props; + const values = () => { + const raw = p().data; + if (!Array.isArray(raw)) return []; + return raw.map(Number).filter(Number.isFinite); + }; + const label = () => str(p().label); + const color = () => resolveSemanticColor(p().color, C.accent); + const width = () => + typeof p().width === 'number' && Number.isFinite(p().width) + ? Math.max(48, Math.round(p().width as number)) + : platform.idiom === 'terminal' + ? 96 + : 128; + const height = () => + typeof p().height === 'number' && Number.isFinite(p().height) + ? Math.max(24, Math.round(p().height as number)) + : platform.idiom === 'terminal' + ? 32 + : 40; + + return ( + + + {label()} + + + + ); +} + +export function renderLineChart(ctx: SpecElementRenderContext): JSX.Element { + const p = ctx.props; + const rows = () => parseRows(p().data); + const series = () => parseSeries(p().series ?? p().yKeys); + const xKey = () => str(p().xKey, 'x'); + const title = () => str(p().title); + const curve = () => resolveCurve(p().curve); + const showDots = () => Boolean(p().dots); + const showGrid = () => p().grid !== false; + const height = () => defaultChartHeight(p().height); + + return ( + rows().length === 0 || series().length === 0} + legend={series} + > + + + + + + + { + const row = rows()[Math.round(val)]; + return row ? String(row[xKey()] ?? '') : ''; + }} + labelStyle={{ ...captionTextStyle, fontSize: 11, color: C.muted, paddingTop: 6 }} + /> + s.key}> + {(s) => ( + ) => Number(row[s().key] ?? 0)} + curve={curve()} + dot={showDots()} + dotStyle={{ + width: 6, + height: 6, + borderRadius: 3, + backgroundColor: s().color, + }} + style={{ stroke: s().color, strokeWidth: 2 }} + /> + )} + + + + + ); +} + +export function renderAreaChart(ctx: SpecElementRenderContext): JSX.Element { + const p = ctx.props; + const rawRows = () => parseRows(p().data); + const series = () => parseSeries(p().series); + const stacked = () => Boolean(p().stacked); + const rows = () => { + const base = rawRows(); + const s = series(); + if (!stacked() || s.length <= 1) return base; + return base.map((row) => { + const next: Record = { ...row }; + let running = 0; + for (const item of s) { + const y0 = running; + running += Number(row[item.key] ?? 0) || 0; + next[`__y0_${item.key}`] = y0; + next[`__y1_${item.key}`] = running; + } + return next; + }); + }; + const xKey = () => str(p().xKey, 'x'); + const title = () => str(p().title); + const curve = () => resolveCurve(p().curve); + const height = () => defaultChartHeight(p().height); + + return ( + rows().length === 0 || series().length === 0} + legend={series} + > + + + + + { + const row = rows()[Math.round(val)]; + return row ? String(row[xKey()] ?? '') : ''; + }} + labelStyle={{ ...captionTextStyle, fontSize: 11, color: C.muted, paddingTop: 6 }} + /> + s.key}> + {(s) => { + const y1Accessor = (row: Record) => + stacked() && series().length > 1 + ? Number(row[`__y1_${s().key}`] ?? 0) + : Number(row[s().key] ?? 0); + const y0Accessor = + stacked() && series().length > 1 + ? (row: Record) => Number(row[`__y0_${s().key}`] ?? 0) + : 0; + return ( + <> + + + + ); + }} + + + + + ); +} + +export function renderBarChart(ctx: SpecElementRenderContext): JSX.Element { + const p = ctx.props; + const rows = () => parseRows(p().data); + const explicitSeries = () => parseSeries(p().series); + const series = (): SeriesItem[] => { + const s = explicitSeries(); + if (s.length > 0) return s; + return [{ key: 'value', label: tr('Value'), color: C.accent }]; + }; + const xKey = () => { + if (typeof p().xKey === 'string' && p().xKey) return p().xKey as string; + const first = rows()[0]; + if (first && 'label' in first) return 'label'; + return 'name'; + }; + const title = () => str(p().title); + const stacked = () => Boolean(p().stacked); + const showValues = () => Boolean(p().showValues); + const height = () => defaultChartHeight(p().height); + + return ( + rows().length === 0} + legend={() => (series().length > 1 ? series() : [])} + > + + + + + { + const row = rows()[Math.round(val)]; + return row ? String(row[xKey()] ?? '') : ''; + }} + labelStyle={{ ...captionTextStyle, fontSize: 11, color: C.muted, paddingTop: 6 }} + /> + s.key}> + {(s) => ( + ) => Number(row[s().key] ?? 0)} + stack={stacked() ? 'total' : undefined} + cornerRadii={{ topLeft: 4, topRight: 4 }} + label={ + showValues() + ? (row: Record) => String(row[s().key] ?? '') + : undefined + } + labelStyle={{ + ...captionTextStyle, + fontSize: 11, + color: C.secondary, + paddingBottom: 2, + }} + style={{ fill: s().color }} + /> + )} + + + + + ); +} + +export function renderPieChart(ctx: SpecElementRenderContext): JSX.Element { + const p = ctx.props; + const slices = () => { + const palette = chartPalette(); + const raw = parseRows(p().data); + return raw + .map((row, idx) => ({ + label: String(row.label ?? row.name ?? tr('Slice {index}', { index: idx + 1 })), + value: Math.max(0, Number(row.value ?? 0) || 0), + color: resolveSemanticColor(row.color, palette[idx % palette.length]!), + })) + .filter((s) => s.value > 0); + }; + const total = () => slices().reduce((sum, s) => sum + s.value, 0); + const title = () => str(p().title); + const donut = () => Boolean(p().donut); + const size = () => defaultChartHeight(p().height, 160); + + return ( + slices().length === 0}> + + s.label), + range: slices().map((s) => s.color), + }} + accessibilityLabel={title() || tr('Pie chart')} + style={{ width: size(), height: size(), flexShrink: 0 }} + > + + + + s.label}> + {(s) => { + const pct = () => + total() > 0 ? `${Math.round((s().value / total()) * 100)}%` : '0%'; + return ( + + + + + {s().label} + + + + {`${s().value} (${pct()})`} + + + ); + }} + + + + + ); +} + +export function renderScatterChart(ctx: SpecElementRenderContext): JSX.Element { + const p = ctx.props; + const rows = () => parseRows(p().data); + const xKey = () => str(p().xKey, 'x'); + const yKey = () => str(p().yKey, 'y'); + const sizeKey = () => str(p().sizeKey); + const title = () => str(p().title); + const height = () => defaultChartHeight(p().height); + + return ( + rows().length === 0}> + + ) => Number(row[xKey()] ?? 0)} + y={(row: Record) => Number(row[yKey()] ?? 0)} + xScale={{ type: 'linear' }} + yScale={{ type: 'linear' }} + accessibilityLabel={title() || tr('Scatter chart')} + style={{ width: '100%', height: height() }} + > + + + + ) => Number(row[sizeKey()] ?? 4) : 4.5} + rScale={sizeKey() ? { type: 'sqrt', range: [3, 12] } : undefined} + style={{ fill: C.accent, opacity: 0.85 }} + /> + + + + ); +} + +export function renderHeatmap(ctx: SpecElementRenderContext): JSX.Element { + const p = ctx.props; + const xLabels = () => (Array.isArray(p().xLabels) ? (p().xLabels as unknown[]).map(String) : []); + const yLabels = () => (Array.isArray(p().yLabels) ? (p().yLabels as unknown[]).map(String) : []); + const cells = () => { + const xs = xLabels(); + const ys = yLabels(); + const matrix = Array.isArray(p().values) ? (p().values as unknown[]) : []; + const flat: Array<{ x: string; y: string; value: number }> = []; + for (let r = 0; r < ys.length; r++) { + const row = Array.isArray(matrix[r]) ? (matrix[r] as unknown[]) : []; + for (let c = 0; c < xs.length; c++) { + const val = Number(row[c] ?? 0); + flat.push({ + x: xs[c]!, + y: ys[r]!, + value: Number.isFinite(val) ? val : 0, + }); + } + } + return flat; + }; + const title = () => str(p().title); + const height = () => defaultChartHeight(p().height, Math.max(120, yLabels().length * 36)); + + return ( + cells().length === 0}> + + + + + String(d.value)} + labelStyle={{ ...captionTextStyle, fontSize: 11, color: C.text }} + /> + + + + ); +} diff --git a/products/maka/workbench/app/spec/components.tsx b/products/maka/workbench/app/spec/components.tsx new file mode 100644 index 00000000000..b1dbb7b111a --- /dev/null +++ b/products/maka/workbench/app/spec/components.tsx @@ -0,0 +1,2915 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { + cleanup, + context, + platform, + Scroll, + signal, + Text, + View, + windowSize, + type JSX, + type ViewRef, +} from 'nsh'; +import { Button, Input, Popover, Select, Slider, TextView, Tooltip } from 'nsh/base'; +import { ChoiceIndicator } from '../ChoiceIndicator'; +import { Icon } from '../Icon'; +import { CodeBlockAction, codeBlockActions, codeBlockStyles } from '../codeBlockLook'; +import { tr } from '../locale'; +import { MarkdownImageView } from '../MarkdownImage'; +import { DialogFooter, DialogHeader, ModalDialog } from '../ModalDialog'; +import { + bodyTextStyle, + C, + captionTextStyle, + codeTextStyle, + controlDisabledStyle, + controlFocusStyle, + controlHoverStyle, + controlStyle, + floatingStyle, + inputBorderRadius, + inputTextStyle, + menuDetailTextStyle, + menuRowStyle, + quietActiveStyle, + quietFocusStyle, + quietHoverStyle, + richTooltipStyle, + secondaryTextStyle, + tooltipStyle, + tooltipTextStyle, +} from '../ui'; +import { + MiniSparkline, + renderAreaChart, + renderBarChart, + renderHeatmap, + renderLineChart, + renderPieChart, + renderScatterChart, + renderSparkline, +} from './charts.tsx'; +import { + useBoundField, + useSpecRuntime, + type SpecComponentRegistry, + type SpecElementRenderContext, +} from './runtime.tsx'; +import { compareTableCells } from './sort.ts'; + +const terminal = platform.idiom === 'terminal'; + +const ActiveTabCtx = context<(() => string) | null>(null); + +function resolveGap(raw: unknown, fallback = 12): number { + if (typeof raw === 'number' && Number.isFinite(raw)) return Math.max(0, raw); + switch (raw) { + case 'none': + return 0; + case 'xs': + return 4; + case 'sm': + return 8; + case 'md': + return 12; + case 'lg': + return 20; + default: + return fallback; + } +} + +function resolvePadding(raw: unknown, fallback = 14): number { + if (typeof raw === 'number' && Number.isFinite(raw)) return Math.max(0, raw); + switch (raw) { + case 'none': + return 0; + case 'sm': + return terminal ? 6 : 10; + case 'md': + return terminal ? 8 : 14; + case 'lg': + return terminal ? 12 : 20; + default: + return terminal ? 8 : fallback; + } +} + +type NormalizedOption = { + label: string; + value: string; + description?: string; + disabled?: boolean; +}; + +function normalizeOptions(raw: unknown): NormalizedOption[] { + if (!Array.isArray(raw)) return []; + const out: NormalizedOption[] = []; + for (const entry of raw) { + if (typeof entry === 'string') { + out.push({ label: entry, value: entry }); + } else if (entry && typeof entry === 'object') { + const obj = entry as Record; + const value = String(obj.value ?? obj.label ?? ''); + const label = String(obj.label ?? value); + out.push({ + label, + value, + description: typeof obj.description === 'string' ? obj.description : undefined, + disabled: Boolean(obj.disabled), + }); + } + } + return out; +} + +function FieldMessages(props: { helper?: () => string; errors: () => string[] }) { + return ( + <> + 0}> + {props.errors()[0]} + + + {props.helper?.()} + + + ); +} + +function renderCard(ctx: SpecElementRenderContext): JSX.Element { + const p = ctx.props; + const title = () => { + const raw = p().title; + return typeof raw === 'string' ? raw : ''; + }; + const description = () => { + if (typeof p().description === 'string' && p().description) return p().description as string; + if (typeof p().subtitle === 'string' && p().subtitle) return p().subtitle as string; + return ''; + }; + const pad = () => resolvePadding(p().padding); + + return ( + + + + + {title()} + + + {description()} + + {ctx.renderSlot('header')} + + + {ctx.children()} + + + {ctx.renderSlot('footer')} + + + + ); +} + +function renderStack(ctx: SpecElementRenderContext): JSX.Element { + const p = ctx.props; + const horizontal = () => p().direction === 'horizontal'; + const align = () => { + switch (p().align) { + case 'start': + return 'flex-start' as const; + case 'center': + return 'center' as const; + case 'end': + return 'flex-end' as const; + case 'baseline': + return 'baseline' as const; + default: + return 'stretch' as const; + } + }; + const justify = () => { + switch (p().justify) { + case 'center': + return 'center' as const; + case 'end': + return 'flex-end' as const; + case 'between': + return 'space-between' as const; + case 'around': + return 'space-around' as const; + default: + return 'flex-start' as const; + } + }; + + return ( + + {ctx.children()} + + ); +} + +function renderGrid(ctx: SpecElementRenderContext): JSX.Element { + const p = ctx.props; + const cols = () => { + const raw = typeof p().columns === 'number' ? Math.round(p().columns as number) : 2; + const clamped = Math.max(1, Math.min(6, raw)); + if (terminal && windowSize().width < 720) return 1; + return clamped; + }; + const gap = () => resolveGap(p().gap, 12); + + return ( + + {ctx.children()} + + ); +} + +function renderBox(ctx: SpecElementRenderContext): JSX.Element { + const p = ctx.props; + return ( + + {ctx.children()} + + ); +} + +function renderSpacer(ctx: SpecElementRenderContext): JSX.Element { + const p = ctx.props; + const size = () => (typeof p().size === 'number' ? Math.max(0, p().size as number) : 12); + return ( + + ); +} + +function renderNewline(ctx: SpecElementRenderContext): JSX.Element { + const count = () => { + const raw = ctx.props().count; + return typeof raw === 'number' && Number.isFinite(raw) ? Math.max(1, Math.round(raw)) : 1; + }; + return ; +} + +function renderSeparator(ctx: SpecElementRenderContext): JSX.Element { + const vertical = () => ctx.props().orientation === 'vertical'; + return ( + + ); +} + +function renderDivider(ctx: SpecElementRenderContext): JSX.Element { + const p = ctx.props; + const title = () => { + const raw = p().title; + return typeof raw === 'string' ? raw : ''; + }; + return ( + + + + {title()} + + + + ); +} + +function renderHeading(ctx: SpecElementRenderContext): JSX.Element { + const p = ctx.props; + const level = () => String(p().level ?? 'h2'); + const size = () => { + if (terminal) return 14; + switch (level()) { + case 'h1': + return 20; + case 'h2': + return 17; + case 'h3': + return 15; + default: + return 14; + } + }; + return ( + + {String(p().text ?? '')} + + ); +} + +function renderText(ctx: SpecElementRenderContext): JSX.Element { + const p = ctx.props; + const color = () => { + switch (p().color) { + case 'secondary': + return C.secondary; + case 'muted': + return C.muted; + case 'accent': + return C.accent; + case 'success': + return C.success; + case 'warning': + return C.warning; + case 'danger': + return C.danger; + default: + return p().variant === 'muted' || p().variant === 'caption' ? C.muted : C.text; + } + }; + const base = () => { + switch (p().variant) { + case 'caption': + case 'muted': + return captionTextStyle; + case 'code': + return codeTextStyle; + case 'lead': + return { fontSize: terminal ? 14 : 16, lineHeight: terminal ? 16 : 24 }; + default: + return secondaryTextStyle; + } + }; + + return ( + + {String(p().text ?? '')} + + ); +} + +function renderMarkdown(ctx: SpecElementRenderContext): JSX.Element { + const runtime = useSpecRuntime(); + const content = () => String(ctx.props().content ?? ''); + return ( + { + const target = e.target; + if (runtime.openLink && (!target || target.isLive())) { + void runtime.openLink(e.url, () => (target ? target.focus() : false)); + } + }} + renderImage={(context) => + runtime.sessionId ? ( + context.image} + sessionId={runtime.sessionId} + linked={context.linked} + flow={context.flow} + lineHeight={context.lineHeight} + /> + ) : ( + + {context.image.alt || context.image.src} + + ) + } + styles={{ + root: { ...secondaryTextStyle, color: C.text }, + paragraph: secondaryTextStyle, + link: { color: C.accent }, + code: { color: C.text, backgroundColor: C.cardRaised, borderRadius: 4 }, + }} + /> + ); +} + +function renderCodeBlock(ctx: SpecElementRenderContext): JSX.Element { + const p = ctx.props; + return ( + } + codeBlockActions={codeBlockActions()} + styles={{ + ...codeBlockStyles(C.text, terminal ? 0 : 8), + codeBlock: { + backgroundColor: C.background, + borderColor: C.line, + borderRadius: terminal ? 0 : 8, + }, + }} + /> + ); +} + +function renderBadge(ctx: SpecElementRenderContext): JSX.Element { + const p = ctx.props; + const tone = () => { + switch (p().variant) { + case 'success': + return { fg: C.success, bg: C.added, border: C.success }; + case 'warning': + return { fg: C.warning, bg: C.cardRaised, border: C.warning }; + case 'danger': + case 'destructive': + return { fg: C.danger, bg: C.removed, border: C.danger }; + case 'info': + return { fg: C.accent, bg: C.cardRaised, border: C.accent }; + case 'outline': + return { fg: C.secondary, bg: 'transparent', border: C.line }; + case 'secondary': + return { fg: C.secondary, bg: C.cardRaised, border: C.line }; + default: + return { fg: C.accent, bg: C.cardRaised, border: C.line }; + } + }; + + return ( + + + {String(p().text ?? '')} + + + ); +} + +function renderStatusLine(ctx: SpecElementRenderContext): JSX.Element { + const p = ctx.props; + const iconInfo = () => { + switch (p().status) { + case 'success': + return { icon: 'statusSuccess' as const, color: C.success }; + case 'warning': + return { icon: 'statusStopped' as const, color: C.warning }; + case 'error': + return { icon: 'statusError' as const, color: C.danger }; + case 'pending': + return { icon: 'statusPending' as const, color: C.muted }; + default: + return { icon: 'statusRecorded' as const, color: C.accent }; + } + }; + + return ( + + + + {String(p().label ?? '')} + + + {String(p().detail)} + + + ); +} + +function renderAlert(ctx: SpecElementRenderContext): JSX.Element { + const p = ctx.props; + const accentColor = () => { + switch (p().type) { + case 'success': + return C.success; + case 'warning': + return C.warning; + case 'error': + case 'destructive': + return C.danger; + default: + return C.accent; + } + }; + + return ( + + + {String(p().title ?? '')} + + + {String(p().description)} + + + ); +} + +function renderCallout(ctx: SpecElementRenderContext): JSX.Element { + const p = ctx.props; + const stripe = () => { + switch (p().type) { + case 'tip': + return C.success; + case 'warning': + return C.warning; + case 'caution': + return C.danger; + default: + return C.accent; + } + }; + + return ( + + + + {String(p().title)} + + + {String(p().content ?? '')} + + ); +} + +function renderMetric(ctx: SpecElementRenderContext): JSX.Element { + const p = ctx.props; + const trendColor = () => { + switch (p().trend) { + case 'up': + return C.success; + case 'down': + return C.danger; + default: + return C.muted; + } + }; + const spark = () => + Array.isArray(p().sparkline) + ? (p().sparkline as unknown[]).map(Number).filter(Number.isFinite) + : []; + + return ( + + {String(p().label ?? '')} + + + + + {String(p().value ?? '')} + + + + {String(p().change)} + + + + + {String(p().caption)} + + + 1}> + + + + + ); +} + +function renderKeyValue(ctx: SpecElementRenderContext): JSX.Element { + const p = ctx.props; + const rows = () => { + if (Array.isArray(p().items)) { + return (p().items as unknown[]) + .filter((r): r is Record => Boolean(r) && typeof r === 'object') + .map((r) => ({ key: String(r.key ?? ''), value: String(r.value ?? '') })); + } + if (p().label !== undefined) { + return [{ key: String(p().label ?? ''), value: String(p().value ?? '') }]; + } + return []; + }; + + return ( + + r.key}> + {(r, index) => ( + + {r().key} + + {r().value} + + + )} + + + ); +} + +type TableCol = { + key: string; + header: string; + align?: 'left' | 'center' | 'right'; + width?: number; + sortable?: boolean; +}; + +function renderTable(ctx: SpecElementRenderContext): JSX.Element { + const p = ctx.props; + const sortCol = signal(null); + const sortAsc = signal(true); + + const columns = (): TableCol[] => { + const raw = Array.isArray(p().columns) ? (p().columns as unknown[]) : []; + return raw.map((col, idx) => { + if (typeof col === 'string') return { key: String(idx), header: col }; + if (col && typeof col === 'object') { + const obj = col as Record; + return { + key: String(obj.key ?? idx), + header: String(obj.header ?? obj.label ?? obj.key ?? ''), + align: (obj.align as 'left' | 'center' | 'right') ?? undefined, + width: typeof obj.width === 'number' ? (obj.width as number) : undefined, + sortable: typeof obj.sortable === 'boolean' ? obj.sortable : undefined, + }; + } + return { key: String(idx), header: '' }; + }); + }; + + const isColumnSortable = (col: TableCol | undefined): boolean => + Boolean(col && (col.sortable ?? Boolean(p().sortable))); + + const normalizedRows = (): string[][] => { + const cols = columns(); + const rawSource = p().rows ?? p().data; + const rawRows = Array.isArray(rawSource) ? (rawSource as unknown[]) : []; + const base = rawRows.map((row) => { + if (Array.isArray(row)) { + return cols.map((_, idx) => String(row[idx] ?? '')); + } + if (row && typeof row === 'object') { + const obj = row as Record; + return cols.map((c) => String(obj[c.key] ?? '')); + } + return cols.map(() => ''); + }); + + const idx = sortCol(); + if (idx === null || idx < 0 || idx >= cols.length || !isColumnSortable(cols[idx])) { + return base; + } + const asc = sortAsc(); + return [...base].sort((a, b) => compareTableCells(a[idx] ?? '', b[idx] ?? '', asc)); + }; + + return ( + + + + `${c.key}:${c.header}`}> + {(col, idx) => { + const colSortable = () => isColumnSortable(col()); + const cellStyle = () => ({ + ...(col().width ? { width: col().width, flexShrink: 0 } : { flex: 1, minWidth: 0 }), + paddingHorizontal: terminal ? 6 : 12, + paddingVertical: terminal ? 4 : 8, + flexDirection: 'row' as const, + alignItems: 'center' as const, + justifyContent: + col().align === 'right' + ? ('flex-end' as const) + : col().align === 'center' + ? ('center' as const) + : ('flex-start' as const), + gap: 4, + }); + return ( + + + {col().header} + + + } + > + + + ); + }} + + + `${idx}:${row.join('|')}`}> + {(row, rIdx) => ( + 0 ? 1 : 0, + borderTopColor: C.line, + backgroundColor: p().striped && rIdx() % 2 === 1 ? C.surface : undefined, + }} + > + c.key}> + {(col, cIdx) => ( + + + {row()[cIdx()] ?? ''} + + + )} + + + )} + + + + {String(p().caption)} + + + ); +} + +function renderList(ctx: SpecElementRenderContext): JSX.Element { + const p = ctx.props; + const items = () => + Array.isArray(p().items) ? (p().items as unknown[]).map((i) => String(i ?? '')) : []; + const ordered = () => Boolean(p().ordered); + + return ( + + `${idx}:${item}`}> + {(item, idx) => ( + 0 ? 1 : 0, + borderTopColor: C.line, + }} + > + + {ordered() ? `${idx() + 1}.` : '•'} + + {item()} + + )} + + {ctx.children()} + + ); +} + +function renderListItem(ctx: SpecElementRenderContext): JSX.Element { + const p = ctx.props; + const hasPress = () => Boolean(ctx.element().on && 'press' in ctx.element().on!); + const body = () => ( + <> + + + {String(p().leading)} + + + + + {String(p().title ?? '')} + + + {String(p().subtitle)} + + + + + {String(p().trailing)} + + + + ); + + return hasPress() ? ( + + ) : ( + + {body()} + + ); +} + +function renderTimeline(ctx: SpecElementRenderContext): JSX.Element { + const p = ctx.props; + const items = () => { + const raw = Array.isArray(p().items) ? (p().items as unknown[]) : []; + return raw + .filter((r): r is Record => Boolean(r) && typeof r === 'object') + .map((r) => ({ + title: String(r.title ?? ''), + description: typeof r.description === 'string' ? r.description : '', + time: typeof r.time === 'string' ? r.time : '', + status: String(r.status ?? 'completed'), + })); + }; + + return ( + + `${idx}:${it.title}`}> + {(it) => { + const dotColor = () => { + switch (it().status) { + case 'completed': + return C.success; + case 'active': + return C.accent; + case 'error': + return C.danger; + default: + return C.muted; + } + }; + return ( + + + + + + {it().title} + + + {it().time} + + + + + {it().description} + + + + + ); + }} + + + ); +} + +function ProgressRow(props: { + ratio: () => number; + label: () => string; + showValue: () => boolean; + color: () => string; +}) { + const clamped = () => Math.max(0, Math.min(1, props.ratio())); + const pct = () => Math.round(clamped() * 100); + const terminalBar = () => { + const slots = 20; + const filled = Math.round(clamped() * slots); + return `${'█'.repeat(filled)}${'░'.repeat(slots - filled)}`; + }; + + return ( + + + + {props.label()} + + {`${pct()}%`} + + + + {terminalBar()}} + > + + + + + + ); +} + +function resolveBarColor(raw: unknown): string { + switch (raw) { + case 'success': + return C.success; + case 'warning': + return C.warning; + case 'danger': + return C.danger; + default: + return C.accent; + } +} + +function renderProgress(ctx: SpecElementRenderContext): JSX.Element { + const p = ctx.props; + const max = () => { + const m = Number(p().max ?? 100); + return Number.isFinite(m) && m > 0 ? m : 100; + }; + const ratio = () => (Number(p().value ?? 0) || 0) / max(); + return ( + (typeof p().label === 'string' ? (p().label as string) : '')} + showValue={() => p().showValue !== false} + color={() => resolveBarColor(p().color)} + /> + ); +} + +function renderProgressBar(ctx: SpecElementRenderContext): JSX.Element { + const p = ctx.props; + const ratio = () => { + const prog = Number(p().progress ?? 0) || 0; + const tot = p().total !== undefined && p().total !== null ? Number(p().total) : undefined; + if (tot !== undefined && Number.isFinite(tot) && tot > 0) return prog / tot; + return prog <= 1 ? prog : prog / 100; + }; + return ( + (typeof p().label === 'string' ? (p().label as string) : '')} + showValue={() => true} + color={() => resolveBarColor(p().color)} + /> + ); +} + +function renderTabs(ctx: SpecElementRenderContext): JSX.Element { + const p = ctx.props; + const bound = useBoundField(ctx.bindings, 'value', () => undefined); + const tabs = () => { + const raw = Array.isArray(p().tabs) ? (p().tabs as unknown[]) : []; + return raw + .filter((t): t is Record => Boolean(t) && typeof t === 'object') + .map((t) => ({ + label: String(t.label ?? t.value ?? ''), + value: String(t.value ?? ''), + content: typeof t.content === 'string' ? t.content : undefined, + })); + }; + const own = signal(null); + const active = () => { + if (p().value !== undefined && p().value !== null) return String(p().value); + if (own() !== null) return own()!; + if (typeof p().defaultValue === 'string' && p().defaultValue) return p().defaultValue as string; + return tabs()[0]?.value ?? ''; + }; + + const inlineContent = () => tabs().find((t) => t.value === active())?.content; + + return ( + + + + t.value}> + {(tab) => { + const selected = () => active() === tab().value; + return ( + + ); + }} + + + + {inlineContent()} + + slotName} + > + {(slotName) => ctx.renderSlot(slotName())} + + {ctx.children()} + + + ); +} + +function renderTabContent(ctx: SpecElementRenderContext): JSX.Element { + const activeTab = ActiveTabCtx(); + const visible = () => { + const explicit = ctx.props().activeValue; + if (typeof explicit === 'string') { + return explicit === String(ctx.props().value ?? ''); + } + return !activeTab || activeTab() === String(ctx.props().value ?? ''); + }; + return {ctx.children()}; +} + +function renderAccordion(ctx: SpecElementRenderContext): JSX.Element { + const p = ctx.props; + const initial = Array.isArray(p().defaultOpen) + ? new Set((p().defaultOpen as unknown[]).map(Number).filter(Number.isInteger)) + : new Set([0]); + const openSet = signal>(initial); + + const items = () => { + const raw = Array.isArray(p().items) ? (p().items as unknown[]) : []; + return raw + .filter((r): r is Record => Boolean(r) && typeof r === 'object') + .map((r) => ({ + title: String(r.title ?? ''), + content: String(r.content ?? ''), + })); + }; + + return ( + + `${idx}:${item.title}`}> + {(item, idx) => { + const isOpen = () => openSet().has(idx()); + return ( + 0 ? 1 : 0, borderTopColor: C.line }}> + + + + + {item().content} + + + + + ); + }} + + + ); +} + +function renderCollapsible(ctx: SpecElementRenderContext): JSX.Element { + const p = ctx.props; + const bound = useBoundField(ctx.bindings, 'open', () => undefined); + const own = signal(Boolean(p().defaultOpen)); + const open = () => (typeof p().open === 'boolean' ? (p().open as boolean) : own()); + + return ( + + + + {ctx.children()} + + + ); +} + +function renderOverlaySheet(ctx: SpecElementRenderContext, size: 'form' | 'editor'): JSX.Element { + const runtime = useSpecRuntime(); + const p = ctx.props; + const openPath = () => (typeof p().openPath === 'string' ? (p().openPath as string) : ''); + const isOpen = () => { + const path = openPath(); + return path ? Boolean(runtime.get(path)) : false; + }; + const close = () => { + const path = openPath(); + if (path) runtime.set(path, false); + }; + + return ( + + + + + {ctx.children()} + + + + + + + ); +} + +function renderPopover(ctx: SpecElementRenderContext): JSX.Element { + const p = ctx.props; + return ( + + {String(p().trigger ?? '')} + + } + > + + + {String(p().content)} + + {ctx.children()} + + + ); +} + +function renderTooltip(ctx: SpecElementRenderContext): JSX.Element { + const p = ctx.props; + const tipText = () => String(p().content ?? ''); + const label = () => (typeof p().text === 'string' ? (p().text as string) : ''); + return ( + 48 ? richTooltipStyle() : tooltipStyle()}> + {tipText()} + + } + style={{ alignSelf: 'flex-start', flexDirection: 'row', alignItems: 'center', gap: 4 }} + > + + + {label()} + + + {ctx.children()} + + ); +} + +function renderCarousel(ctx: SpecElementRenderContext): JSX.Element { + const p = ctx.props; + const idx = signal(0); + const items = () => { + const raw = Array.isArray(p().items) ? (p().items as unknown[]) : []; + return raw + .filter((r): r is Record => Boolean(r) && typeof r === 'object') + .map((r) => ({ + title: typeof r.title === 'string' ? r.title : '', + description: typeof r.description === 'string' ? r.description : '', + })); + }; + const childKeys = () => ctx.element().children ?? []; + const count = () => (items().length > 0 ? items().length : childKeys().length); + const current = () => Math.max(0, Math.min(Math.max(0, count() - 1), idx())); + + return ( + + 0} else={ctx.children()}> + + + + {items()[current()]?.title} + + + + + {items()[current()]?.description} + + + + + 1}> + + + + {tr('Page {page} of {total}', { page: current() + 1, total: count() })} + + + + + + ); +} + +function renderImage(ctx: SpecElementRenderContext): JSX.Element { + const runtime = useSpecRuntime(); + const p = ctx.props; + const src = () => (typeof p().src === 'string' ? (p().src as string) : ''); + const alt = () => String(p().alt ?? ''); + const w = () => (typeof p().width === 'number' ? (p().width as number) : undefined); + const h = () => (typeof p().height === 'number' ? (p().height as number) : undefined); + + return ( + + + {alt() || src()} + + } + > + s}> + {(currentSrc) => ( + + ({ + src: currentSrc(), + alt: alt(), + width: w(), + height: h(), + })} + sessionId={runtime.sessionId!} + linked={false} + flow="block" + lineHeight={20} + /> + + )} + + + ); +} + +function renderAvatar(ctx: SpecElementRenderContext): JSX.Element { + const p = ctx.props; + const initials = () => { + const parts = String(p().name ?? '') + .trim() + .split(/\s+/) + .filter(Boolean); + return parts + .slice(0, 2) + .map((part) => part[0]!.toUpperCase()) + .join(''); + }; + const dim = () => (p().size === 'sm' ? 24 : p().size === 'lg' ? 40 : 32); + + return ( + + + {initials() || '?'} + + + ); +} + +function renderSkeleton(ctx: SpecElementRenderContext): JSX.Element { + const p = ctx.props; + const count = () => Math.max(1, Math.min(8, Number(p().lines ?? 2) || 2)); + const h = () => (typeof p().height === 'number' ? (p().height as number) : 14); + return ( + + i)} key={(i) => String(i)}> + {(i) => ( + 1 ? '68%' : '100%', + height: h(), + borderRadius: terminal ? 0 : 6, + backgroundColor: C.cardRaised, + }} + /> + )} + + + ); +} + +function renderSpinner(ctx: SpecElementRenderContext): JSX.Element { + const label = () => + typeof ctx.props().label === 'string' && ctx.props().label + ? (ctx.props().label as string) + : tr('Loading…'); + return ( + + + {label()} + + ); +} + +function renderEmptyState(ctx: SpecElementRenderContext): JSX.Element { + const p = ctx.props; + return ( + + + {String(p().title ?? '')} + + + + {String(p().description)} + + + {ctx.children()} + + ); +} + +function renderTextField(ctx: SpecElementRenderContext, multiline: boolean): JSX.Element { + const p = ctx.props; + const bound = useBoundField( + ctx.bindings, + 'value', + () => p().checks, + () => p().validateOn, + ); + const own = signal(''); + const val = () => (p().value !== undefined && p().value !== null ? String(p().value) : own()); + const label = () => (typeof p().label === 'string' ? (p().label as string) : ''); + const disabled = () => Boolean(p().disabled); + const masked = () => p().type === 'password' || Boolean(p().masked); + const rows = () => Math.max(2, Math.min(12, Number(p().rows ?? 3) || 3)); + const lineHeight = terminal ? 16 : 20; + + return ( + + + + {label()} + + + + { + own.set(value); + bound.write(value); + ctx.emit('change'); + }} + onBlur={() => { + bound.blur(); + ctx.emit('blur'); + }} + onSubmit={() => ctx.emit('submit')} + style={{ + ...inputTextStyle, + color: disabled() ? C.muted : C.text, + placeholderColor: C.muted, + backgroundColor: C.background, + borderWidth: 1, + borderColor: bound.errors().length > 0 ? C.danger : C.line, + borderRadius: inputBorderRadius, + paddingHorizontal: terminal ? 0 : 10, + paddingVertical: terminal ? 0 : multiline ? 8 : 4, + ...(multiline ? { flex: 1 } : { height: terminal ? 16 : 32 }), + }} + focusStyle={{ borderColor: C.accent }} + /> + + (typeof p().helperText === 'string' ? (p().helperText as string) : '')} + errors={bound.errors} + /> + + ); +} + +function renderNumberInput(ctx: SpecElementRenderContext): JSX.Element { + const p = ctx.props; + const bound = useBoundField(ctx.bindings, 'value', () => p().checks); + const own = signal(0); + const draft = signal(null); + const step = () => { + const s = Number(p().step ?? 1); + return Number.isFinite(s) && s > 0 ? s : 1; + }; + const clampNum = (raw: number): number => { + const lo = typeof p().min === 'number' ? (p().min as number) : Number.NEGATIVE_INFINITY; + const hi = typeof p().max === 'number' ? (p().max as number) : Number.POSITIVE_INFINITY; + return Math.min(hi, Math.max(lo, raw)); + }; + const current = () => { + const v = p().value !== undefined && p().value !== null ? Number(p().value) : own(); + return Number.isFinite(v) ? v : 0; + }; + const commit = (next: number) => { + const clamped = clampNum(next); + own.set(clamped); + bound.write(clamped); + ctx.emit('change'); + }; + const flushDraft = () => { + const raw = draft(); + draft.set(null); + if (raw === null) return; + const trimmed = raw.trim(); + const parsed = Number(trimmed); + if (trimmed !== '' && Number.isFinite(parsed)) { + commit(parsed); + } + }; + const stepBy = (delta: number) => { + const raw = draft(); + draft.set(null); + let base = current(); + if (raw !== null) { + const trimmed = raw.trim(); + const parsed = Number(trimmed); + if (trimmed !== '' && Number.isFinite(parsed)) { + base = clampNum(parsed); + } + } + commit(base + delta); + }; + const label = () => (typeof p().label === 'string' ? (p().label as string) : ''); + const disabled = () => Boolean(p().disabled); + const decreaseLabel = () => + label() ? tr('Decrease {label}', { label: label() }) : tr('Decrease value'); + const increaseLabel = () => + label() ? tr('Increase {label}', { label: label() }) : tr('Increase value'); + + return ( + + + + {label()} + + + + + { + draft.set(value); + const trimmed = value.trim(); + const parsed = Number(trimmed); + if (trimmed !== '' && Number.isFinite(parsed) && clampNum(parsed) === parsed) { + own.set(parsed); + bound.write(parsed); + ctx.emit('change'); + } + }} + onBlur={() => { + flushDraft(); + bound.blur(); + ctx.emit('blur'); + }} + onSubmit={() => { + flushDraft(); + ctx.emit('submit'); + }} + style={{ + ...inputTextStyle, + width: 88, + height: terminal ? 16 : 32, + textAlign: 'center', + color: disabled() ? C.muted : C.text, + backgroundColor: C.background, + borderWidth: 1, + borderColor: bound.errors().length > 0 ? C.danger : C.line, + borderRadius: inputBorderRadius, + paddingHorizontal: terminal ? 0 : 8, + }} + focusStyle={{ borderColor: C.accent }} + /> + + + + + ); +} + +function renderSelect(ctx: SpecElementRenderContext): JSX.Element { + const p = ctx.props; + const bound = useBoundField(ctx.bindings, 'value', () => p().checks); + const options = () => normalizeOptions(p().options); + const own = signal(null); + const currentVal = () => + p().value !== undefined && p().value !== null && p().value !== '' ? String(p().value) : own(); + const currentOption = () => options().find((o) => o.value === currentVal()); + const label = () => (typeof p().label === 'string' ? (p().label as string) : ''); + const placeholder = () => + typeof p().placeholder === 'string' && p().placeholder + ? (p().placeholder as string) + : tr('Select…'); + + return ( + + + + {label()} + + +