Add Tauri terminal bridge for chat and commands
24
MosaicIQ/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
1
MosaicIQ/.nvmrc
Normal file
@@ -0,0 +1 @@
|
||||
24.14.1
|
||||
3
MosaicIQ/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["tauri-apps.tauri-vscode", "rust-lang.rust-analyzer"]
|
||||
}
|
||||
11
MosaicIQ/README.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# Tauri + React + Typescript
|
||||
|
||||
This template should help get you started developing with Tauri, React and Typescript in Vite.
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Rig Agent Harness Architecture](./docs/rig-agent-harness.md)
|
||||
|
||||
## Recommended IDE Setup
|
||||
|
||||
- [VS Code](https://code.visualstudio.com/) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer)
|
||||
27
MosaicIQ/agent.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# Agent Standards
|
||||
|
||||
This repository should favor durable backend code over fast patches. The baseline is:
|
||||
|
||||
## Core rules
|
||||
|
||||
- Keep Tauri commands thin. Input validation, orchestration, and state changes belong in backend services, not in command handlers.
|
||||
- Do not leave dead code or disconnected module trees in the crate. If Rust cannot compile it, it is not part of the backend.
|
||||
- Prefer explicit types and narrow module boundaries over convenience abstractions that hide behavior.
|
||||
- Avoid shortcuts such as `unwrap`, `expect`, silent fallbacks, or placeholder logic in production paths.
|
||||
- Every backend behavior change should preserve `cargo check` and add or update tests when logic changes.
|
||||
|
||||
## Rust patterns
|
||||
|
||||
- Use conventional Rust module layout: `mod.rs` or flat sibling modules, not nested filename patterns that Cargo will ignore.
|
||||
- Return typed errors from backend services and convert them at the Tauri boundary only when required by the command interface.
|
||||
- Keep shared mutable state behind a narrow API. Do not let command handlers reach directly into ad hoc maps or storage.
|
||||
- Add doc comments to public types and functions. Add inline comments only when explaining why a constraint exists.
|
||||
- Prefer small, composable functions over commented blocks of procedural logic.
|
||||
|
||||
## Review bar
|
||||
|
||||
- No partially wired backend folders.
|
||||
- No mock or template code left in the active backend path.
|
||||
- No new public API without documentation.
|
||||
- No new stateful logic without at least focused unit coverage.
|
||||
- No structural change without verifying the crate still compiles.
|
||||
373
MosaicIQ/bun.lock
Normal file
@@ -0,0 +1,373 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "mosaiciq",
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"@tauri-apps/plugin-store": "~2",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"tailwindcss": "^4.2.2",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@vitejs/plugin-react": "^4.6.0",
|
||||
"typescript": "~5.8.3",
|
||||
"vite": "^7.0.4",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
|
||||
|
||||
"@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="],
|
||||
|
||||
"@babel/core": ["@babel/core@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="],
|
||||
|
||||
"@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="],
|
||||
|
||||
"@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="],
|
||||
|
||||
"@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="],
|
||||
|
||||
"@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="],
|
||||
|
||||
"@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="],
|
||||
|
||||
"@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="],
|
||||
|
||||
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
|
||||
|
||||
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
|
||||
|
||||
"@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="],
|
||||
|
||||
"@babel/helpers": ["@babel/helpers@7.29.2", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.29.0" } }, "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw=="],
|
||||
|
||||
"@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="],
|
||||
|
||||
"@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw=="],
|
||||
|
||||
"@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="],
|
||||
|
||||
"@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="],
|
||||
|
||||
"@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="],
|
||||
|
||||
"@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
|
||||
|
||||
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.4", "", { "os": "aix", "cpu": "ppc64" }, "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q=="],
|
||||
|
||||
"@esbuild/android-arm": ["@esbuild/android-arm@0.27.4", "", { "os": "android", "cpu": "arm" }, "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ=="],
|
||||
|
||||
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.4", "", { "os": "android", "cpu": "arm64" }, "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw=="],
|
||||
|
||||
"@esbuild/android-x64": ["@esbuild/android-x64@0.27.4", "", { "os": "android", "cpu": "x64" }, "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw=="],
|
||||
|
||||
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ=="],
|
||||
|
||||
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw=="],
|
||||
|
||||
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.4", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw=="],
|
||||
|
||||
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ=="],
|
||||
|
||||
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.4", "", { "os": "linux", "cpu": "arm" }, "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg=="],
|
||||
|
||||
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA=="],
|
||||
|
||||
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.4", "", { "os": "linux", "cpu": "ia32" }, "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA=="],
|
||||
|
||||
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.4", "", { "os": "linux", "cpu": "none" }, "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA=="],
|
||||
|
||||
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.4", "", { "os": "linux", "cpu": "none" }, "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw=="],
|
||||
|
||||
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA=="],
|
||||
|
||||
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.4", "", { "os": "linux", "cpu": "none" }, "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw=="],
|
||||
|
||||
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA=="],
|
||||
|
||||
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.4", "", { "os": "linux", "cpu": "x64" }, "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA=="],
|
||||
|
||||
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.4", "", { "os": "none", "cpu": "arm64" }, "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q=="],
|
||||
|
||||
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.4", "", { "os": "none", "cpu": "x64" }, "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg=="],
|
||||
|
||||
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.4", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow=="],
|
||||
|
||||
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.4", "", { "os": "openbsd", "cpu": "x64" }, "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ=="],
|
||||
|
||||
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.4", "", { "os": "none", "cpu": "arm64" }, "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg=="],
|
||||
|
||||
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.4", "", { "os": "sunos", "cpu": "x64" }, "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g=="],
|
||||
|
||||
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg=="],
|
||||
|
||||
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw=="],
|
||||
|
||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.4", "", { "os": "win32", "cpu": "x64" }, "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg=="],
|
||||
|
||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||
|
||||
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
||||
|
||||
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
|
||||
|
||||
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
||||
|
||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||
|
||||
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="],
|
||||
|
||||
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.1", "", { "os": "android", "cpu": "arm" }, "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA=="],
|
||||
|
||||
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.60.1", "", { "os": "android", "cpu": "arm64" }, "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA=="],
|
||||
|
||||
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.60.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw=="],
|
||||
|
||||
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.60.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew=="],
|
||||
|
||||
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.60.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w=="],
|
||||
|
||||
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.60.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g=="],
|
||||
|
||||
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.60.1", "", { "os": "linux", "cpu": "arm" }, "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g=="],
|
||||
|
||||
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.60.1", "", { "os": "linux", "cpu": "arm" }, "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg=="],
|
||||
|
||||
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.60.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ=="],
|
||||
|
||||
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.60.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA=="],
|
||||
|
||||
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ=="],
|
||||
|
||||
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw=="],
|
||||
|
||||
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.60.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw=="],
|
||||
|
||||
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.60.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg=="],
|
||||
|
||||
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg=="],
|
||||
|
||||
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg=="],
|
||||
|
||||
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.60.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ=="],
|
||||
|
||||
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.60.1", "", { "os": "linux", "cpu": "x64" }, "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg=="],
|
||||
|
||||
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.60.1", "", { "os": "linux", "cpu": "x64" }, "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w=="],
|
||||
|
||||
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.60.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw=="],
|
||||
|
||||
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.60.1", "", { "os": "none", "cpu": "arm64" }, "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA=="],
|
||||
|
||||
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.60.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g=="],
|
||||
|
||||
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.60.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg=="],
|
||||
|
||||
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.60.1", "", { "os": "win32", "cpu": "x64" }, "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg=="],
|
||||
|
||||
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.1", "", { "os": "win32", "cpu": "x64" }, "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ=="],
|
||||
|
||||
"@tailwindcss/node": ["@tailwindcss/node@4.2.2", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.2" } }, "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA=="],
|
||||
|
||||
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.2", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.2", "@tailwindcss/oxide-darwin-arm64": "4.2.2", "@tailwindcss/oxide-darwin-x64": "4.2.2", "@tailwindcss/oxide-freebsd-x64": "4.2.2", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", "@tailwindcss/oxide-linux-x64-musl": "4.2.2", "@tailwindcss/oxide-wasm32-wasi": "4.2.2", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" } }, "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg=="],
|
||||
|
||||
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.2.2", "", { "os": "android", "cpu": "arm64" }, "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg=="],
|
||||
|
||||
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.2.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg=="],
|
||||
|
||||
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.2.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw=="],
|
||||
|
||||
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.2.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2", "", { "os": "linux", "cpu": "arm" }, "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.2.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.2.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.2.2", "", { "os": "linux", "cpu": "x64" }, "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.2.2", "", { "os": "linux", "cpu": "x64" }, "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.2.2", "", { "dependencies": { "@emnapi/core": "^1.8.1", "@emnapi/runtime": "^1.8.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.1", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q=="],
|
||||
|
||||
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.2.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ=="],
|
||||
|
||||
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.2.2", "", { "os": "win32", "cpu": "x64" }, "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA=="],
|
||||
|
||||
"@tailwindcss/vite": ["@tailwindcss/vite@4.2.2", "", { "dependencies": { "@tailwindcss/node": "4.2.2", "@tailwindcss/oxide": "4.2.2", "tailwindcss": "4.2.2" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w=="],
|
||||
|
||||
"@tauri-apps/api": ["@tauri-apps/api@2.10.1", "", {}, "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw=="],
|
||||
|
||||
"@tauri-apps/cli": ["@tauri-apps/cli@2.10.1", "", { "optionalDependencies": { "@tauri-apps/cli-darwin-arm64": "2.10.1", "@tauri-apps/cli-darwin-x64": "2.10.1", "@tauri-apps/cli-linux-arm-gnueabihf": "2.10.1", "@tauri-apps/cli-linux-arm64-gnu": "2.10.1", "@tauri-apps/cli-linux-arm64-musl": "2.10.1", "@tauri-apps/cli-linux-riscv64-gnu": "2.10.1", "@tauri-apps/cli-linux-x64-gnu": "2.10.1", "@tauri-apps/cli-linux-x64-musl": "2.10.1", "@tauri-apps/cli-win32-arm64-msvc": "2.10.1", "@tauri-apps/cli-win32-ia32-msvc": "2.10.1", "@tauri-apps/cli-win32-x64-msvc": "2.10.1" }, "bin": { "tauri": "tauri.js" } }, "sha512-jQNGF/5quwORdZSSLtTluyKQ+o6SMa/AUICfhf4egCGFdMHqWssApVgYSbg+jmrZoc8e1DscNvjTnXtlHLS11g=="],
|
||||
|
||||
"@tauri-apps/cli-darwin-arm64": ["@tauri-apps/cli-darwin-arm64@2.10.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Z2OjCXiZ+fbYZy7PmP3WRnOpM9+Fy+oonKDEmUE6MwN4IGaYqgceTjwHucc/kEEYZos5GICve35f7ZiizgqEnQ=="],
|
||||
|
||||
"@tauri-apps/cli-darwin-x64": ["@tauri-apps/cli-darwin-x64@2.10.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-V/irQVvjPMGOTQqNj55PnQPVuH4VJP8vZCN7ajnj+ZS8Kom1tEM2hR3qbbIRoS3dBKs5mbG8yg1WC+97dq17Pw=="],
|
||||
|
||||
"@tauri-apps/cli-linux-arm-gnueabihf": ["@tauri-apps/cli-linux-arm-gnueabihf@2.10.1", "", { "os": "linux", "cpu": "arm" }, "sha512-Hyzwsb4VnCWKGfTw+wSt15Z2pLw2f0JdFBfq2vHBOBhvg7oi6uhKiF87hmbXOBXUZaGkyRDkCHsdzJcIfoJC2w=="],
|
||||
|
||||
"@tauri-apps/cli-linux-arm64-gnu": ["@tauri-apps/cli-linux-arm64-gnu@2.10.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-OyOYs2t5GkBIvyWjA1+h4CZxTcdz1OZPCWAPz5DYEfB0cnWHERTnQ/SLayQzncrT0kwRoSfSz9KxenkyJoTelA=="],
|
||||
|
||||
"@tauri-apps/cli-linux-arm64-musl": ["@tauri-apps/cli-linux-arm64-musl@2.10.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-MIj78PDDGjkg3NqGptDOGgfXks7SYJwhiMh8SBoZS+vfdz7yP5jN18bNaLnDhsVIPARcAhE1TlsZe/8Yxo2zqg=="],
|
||||
|
||||
"@tauri-apps/cli-linux-riscv64-gnu": ["@tauri-apps/cli-linux-riscv64-gnu@2.10.1", "", { "os": "linux", "cpu": "none" }, "sha512-X0lvOVUg8PCVaoEtEAnpxmnkwlE1gcMDTqfhbefICKDnOTJ5Est3qL0SrWxizDackIOKBcvtpejrSiVpuJI1kw=="],
|
||||
|
||||
"@tauri-apps/cli-linux-x64-gnu": ["@tauri-apps/cli-linux-x64-gnu@2.10.1", "", { "os": "linux", "cpu": "x64" }, "sha512-2/12bEzsJS9fAKybxgicCDFxYD1WEI9kO+tlDwX5znWG2GwMBaiWcmhGlZ8fi+DMe9CXlcVarMTYc0L3REIRxw=="],
|
||||
|
||||
"@tauri-apps/cli-linux-x64-musl": ["@tauri-apps/cli-linux-x64-musl@2.10.1", "", { "os": "linux", "cpu": "x64" }, "sha512-Y8J0ZzswPz50UcGOFuXGEMrxbjwKSPgXftx5qnkuMs2rmwQB5ssvLb6tn54wDSYxe7S6vlLob9vt0VKuNOaCIQ=="],
|
||||
|
||||
"@tauri-apps/cli-win32-arm64-msvc": ["@tauri-apps/cli-win32-arm64-msvc@2.10.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-iSt5B86jHYAPJa/IlYw++SXtFPGnWtFJriHn7X0NFBVunF6zu9+/zOn8OgqIWSl8RgzhLGXQEEtGBdR4wzpVgg=="],
|
||||
|
||||
"@tauri-apps/cli-win32-ia32-msvc": ["@tauri-apps/cli-win32-ia32-msvc@2.10.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-gXyxgEzsFegmnWywYU5pEBURkcFN/Oo45EAwvZrHMh+zUSEAvO5E8TXsgPADYm31d1u7OQU3O3HsYfVBf2moHw=="],
|
||||
|
||||
"@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/cli-win32-x64-msvc@2.10.1", "", { "os": "win32", "cpu": "x64" }, "sha512-6Cn7YpPFwzChy0ERz6djKEmUehWrYlM+xTaNzGPgZocw3BD7OfwfWHKVWxXzdjEW2KfKkHddfdxK1XXTYqBRLg=="],
|
||||
|
||||
"@tauri-apps/plugin-opener": ["@tauri-apps/plugin-opener@2.5.3", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-CCcUltXMOfUEArbf3db3kCE7Ggy1ExBEBl51Ko2ODJ6GDYHRp1nSNlQm5uNCFY5k7/ufaK5Ib3Du/Zir19IYQQ=="],
|
||||
|
||||
"@tauri-apps/plugin-store": ["@tauri-apps/plugin-store@2.4.2", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-0ClHS50Oq9HEvLPhNzTNFxbWVOqoAp3dRvtewQBeqfIQ0z5m3JRnOISIn2ZVPCrQC0MyGyhTS9DWhHjpigQE7A=="],
|
||||
|
||||
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
|
||||
|
||||
"@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],
|
||||
|
||||
"@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="],
|
||||
|
||||
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||
|
||||
"@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
|
||||
|
||||
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
|
||||
|
||||
"@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
|
||||
|
||||
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.13", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-BL2sTuHOdy0YT1lYieUxTw/QMtPBC3pmlJC6xk8BBYVv6vcw3SGdKemQ+Xsx9ik2F/lYDO9tqsFQH1r9PFuHKw=="],
|
||||
|
||||
"browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="],
|
||||
|
||||
"caniuse-lite": ["caniuse-lite@1.0.30001782", "", {}, "sha512-dZcaJLJeDMh4rELYFw1tvSn1bhZWYFOt468FcbHHxx/Z/dFidd1I6ciyFdi3iwfQCyOjqo9upF6lGQYtMiJWxw=="],
|
||||
|
||||
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
|
||||
|
||||
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"electron-to-chromium": ["electron-to-chromium@1.5.329", "", {}, "sha512-/4t+AS1l4S3ZC0Ja7PHFIWeBIxGA3QGqV8/yKsP36v7NcyUCl+bIcmw6s5zVuMIECWwBrAK/6QLzTmbJChBboQ=="],
|
||||
|
||||
"enhanced-resolve": ["enhanced-resolve@5.20.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA=="],
|
||||
|
||||
"esbuild": ["esbuild@0.27.4", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.4", "@esbuild/android-arm": "0.27.4", "@esbuild/android-arm64": "0.27.4", "@esbuild/android-x64": "0.27.4", "@esbuild/darwin-arm64": "0.27.4", "@esbuild/darwin-x64": "0.27.4", "@esbuild/freebsd-arm64": "0.27.4", "@esbuild/freebsd-x64": "0.27.4", "@esbuild/linux-arm": "0.27.4", "@esbuild/linux-arm64": "0.27.4", "@esbuild/linux-ia32": "0.27.4", "@esbuild/linux-loong64": "0.27.4", "@esbuild/linux-mips64el": "0.27.4", "@esbuild/linux-ppc64": "0.27.4", "@esbuild/linux-riscv64": "0.27.4", "@esbuild/linux-s390x": "0.27.4", "@esbuild/linux-x64": "0.27.4", "@esbuild/netbsd-arm64": "0.27.4", "@esbuild/netbsd-x64": "0.27.4", "@esbuild/openbsd-arm64": "0.27.4", "@esbuild/openbsd-x64": "0.27.4", "@esbuild/openharmony-arm64": "0.27.4", "@esbuild/sunos-x64": "0.27.4", "@esbuild/win32-arm64": "0.27.4", "@esbuild/win32-ia32": "0.27.4", "@esbuild/win32-x64": "0.27.4" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ=="],
|
||||
|
||||
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
||||
|
||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
|
||||
|
||||
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||
|
||||
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
|
||||
|
||||
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||
|
||||
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
|
||||
|
||||
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
|
||||
|
||||
"lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
|
||||
|
||||
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
|
||||
|
||||
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="],
|
||||
|
||||
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="],
|
||||
|
||||
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="],
|
||||
|
||||
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="],
|
||||
|
||||
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="],
|
||||
|
||||
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="],
|
||||
|
||||
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="],
|
||||
|
||||
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="],
|
||||
|
||||
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="],
|
||||
|
||||
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
|
||||
|
||||
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
||||
|
||||
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||
|
||||
"node-releases": ["node-releases@2.0.36", "", {}, "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
|
||||
|
||||
"postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="],
|
||||
|
||||
"react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
|
||||
|
||||
"react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="],
|
||||
|
||||
"react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="],
|
||||
|
||||
"rollup": ["rollup@4.60.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.1", "@rollup/rollup-android-arm64": "4.60.1", "@rollup/rollup-darwin-arm64": "4.60.1", "@rollup/rollup-darwin-x64": "4.60.1", "@rollup/rollup-freebsd-arm64": "4.60.1", "@rollup/rollup-freebsd-x64": "4.60.1", "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", "@rollup/rollup-linux-arm-musleabihf": "4.60.1", "@rollup/rollup-linux-arm64-gnu": "4.60.1", "@rollup/rollup-linux-arm64-musl": "4.60.1", "@rollup/rollup-linux-loong64-gnu": "4.60.1", "@rollup/rollup-linux-loong64-musl": "4.60.1", "@rollup/rollup-linux-ppc64-gnu": "4.60.1", "@rollup/rollup-linux-ppc64-musl": "4.60.1", "@rollup/rollup-linux-riscv64-gnu": "4.60.1", "@rollup/rollup-linux-riscv64-musl": "4.60.1", "@rollup/rollup-linux-s390x-gnu": "4.60.1", "@rollup/rollup-linux-x64-gnu": "4.60.1", "@rollup/rollup-linux-x64-musl": "4.60.1", "@rollup/rollup-openbsd-x64": "4.60.1", "@rollup/rollup-openharmony-arm64": "4.60.1", "@rollup/rollup-win32-arm64-msvc": "4.60.1", "@rollup/rollup-win32-ia32-msvc": "4.60.1", "@rollup/rollup-win32-x64-gnu": "4.60.1", "@rollup/rollup-win32-x64-msvc": "4.60.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w=="],
|
||||
|
||||
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
||||
|
||||
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"tailwindcss": ["tailwindcss@4.2.2", "", {}, "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q=="],
|
||||
|
||||
"tapable": ["tapable@2.3.2", "", {}, "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA=="],
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
||||
|
||||
"typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
|
||||
|
||||
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
|
||||
|
||||
"vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="],
|
||||
|
||||
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.9.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.9.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.2", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, "bundled": true }, "sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
}
|
||||
}
|
||||
357
MosaicIQ/docs/rig-agent-harness.md
Normal file
@@ -0,0 +1,357 @@
|
||||
# Rig Agent Harness Architecture
|
||||
|
||||
Status: Proposed
|
||||
|
||||
## Purpose
|
||||
|
||||
This document defines the planned architecture for the MosaicIQ agent harness using Rig inside the existing Tauri application. It is intentionally limited to architecture and implementation planning. It does not introduce any runtime changes by itself.
|
||||
|
||||
## Decision Summary
|
||||
|
||||
MosaicIQ should use Rig as the primary agent runtime inside the Rust/Tauri backend.
|
||||
|
||||
Pi in RPC mode should not be the default agent architecture for this project. It may be considered later only as a specialized sidecar for narrowly scoped power-user workflows that justify a separate process boundary.
|
||||
|
||||
## Why This Direction Fits MosaicIQ
|
||||
|
||||
The current repository already points toward an embedded Rust-first architecture:
|
||||
|
||||
- The desktop app is built with Tauri plus React.
|
||||
- `rig-core` is already present in `src-tauri/Cargo.toml`.
|
||||
- The current "agent" lives entirely in the frontend and uses local mock data and intent matching.
|
||||
- The Rust backend is not yet acting as the application agent runtime.
|
||||
- Tauri capabilities are still minimal, which is a good starting point for a constrained tool model.
|
||||
|
||||
Given that shape, an embedded Rig harness is the shortest path from the current state to a real in-app research agent without adding a second runtime to package, supervise, and secure.
|
||||
|
||||
## Product Goals
|
||||
|
||||
The harness should support three classes of behavior:
|
||||
|
||||
1. Interactive in-app research
|
||||
- Answer requests in the terminal-like UI.
|
||||
- Return structured results that map cleanly to existing panels such as company, news, portfolio, and analysis.
|
||||
|
||||
2. Non-interactive agentic workflows
|
||||
- Run background jobs such as scanning news, refreshing watchlists, summarizing updates, and generating research artifacts.
|
||||
- Report progress and completion back to the UI without blocking the main interaction loop.
|
||||
|
||||
3. Constrained file manipulation
|
||||
- Save notes, reports, exported research, and other app-managed artifacts.
|
||||
- Avoid broad arbitrary filesystem access in the initial design.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Building a general-purpose coding agent as the primary product runtime
|
||||
- Granting unrestricted shell execution to the in-app agent
|
||||
- Allowing arbitrary filesystem writes outside app-managed locations in the first version
|
||||
- Introducing a second long-lived Node runtime unless a later requirement clearly demands it
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```text
|
||||
React UI
|
||||
|
|
||||
v
|
||||
Tauri invoke/events bridge
|
||||
|
|
||||
v
|
||||
Rust agent harness (Rig)
|
||||
|- session manager
|
||||
|- prompt orchestration
|
||||
|- structured output parser
|
||||
|- tool router
|
||||
|- background job coordinator
|
||||
|
|
||||
+--> market/news/data adapters
|
||||
+--> app storage adapters
|
||||
+--> constrained file tools
|
||||
+--> model provider adapters
|
||||
```
|
||||
|
||||
## Planned Components
|
||||
|
||||
### 1. UI Agent Client
|
||||
|
||||
The React layer should become a thin client for the agent runtime rather than the place where intents are classified.
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- Send interactive prompts to Rust
|
||||
- Subscribe to agent events and status updates
|
||||
- Render structured outputs into the existing panel system
|
||||
- Surface approval requests for restricted actions
|
||||
- Display progress for long-running jobs
|
||||
|
||||
Out of scope:
|
||||
|
||||
- Model orchestration
|
||||
- business logic for tool execution
|
||||
- local intent heuristics as the source of truth
|
||||
|
||||
### 2. Tauri Bridge Layer
|
||||
|
||||
Tauri should expose a narrow application protocol for agent actions.
|
||||
|
||||
Planned command surface:
|
||||
|
||||
- `agent_prompt`
|
||||
- Start or continue an interactive agent turn for a workspace
|
||||
- `start_background_job`
|
||||
- Launch a non-interactive workflow
|
||||
- `get_job_status`
|
||||
- Query a job snapshot
|
||||
- `cancel_job`
|
||||
- Stop an in-progress job
|
||||
- `approve_action`
|
||||
- Confirm a restricted write or other gated operation
|
||||
|
||||
Planned event surface:
|
||||
|
||||
- `agent_delta`
|
||||
- `agent_result`
|
||||
- `agent_tool_started`
|
||||
- `agent_tool_finished`
|
||||
- `agent_needs_approval`
|
||||
- `job_progress`
|
||||
- `job_completed`
|
||||
- `job_failed`
|
||||
|
||||
The bridge should stay application-specific. It should not mirror a generic coding-agent protocol unless MosaicIQ later proves that it needs one.
|
||||
|
||||
### 3. Rig Agent Core
|
||||
|
||||
Rig should be the orchestration engine inside Rust.
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- Maintain per-workspace session context
|
||||
- Compose prompts from user input, workspace state, and tool results
|
||||
- Select and invoke tools
|
||||
- Produce structured outputs for UI rendering
|
||||
- Route long-running work to the background job layer
|
||||
|
||||
Design constraints:
|
||||
|
||||
- Responses should prefer typed payloads over raw prose when the UI can render a richer result.
|
||||
- Tool definitions should remain narrow and finance/product specific.
|
||||
- The agent must degrade safely when tools or providers fail.
|
||||
|
||||
### 4. Tool Router
|
||||
|
||||
The tool router is the critical control surface of the harness.
|
||||
|
||||
Initial tool categories:
|
||||
|
||||
- Finance data lookup
|
||||
- company profile
|
||||
- quote snapshot
|
||||
- portfolio snapshot
|
||||
- News and summarization
|
||||
- latest news scan
|
||||
- company-filtered news
|
||||
- digest generation
|
||||
- Analysis helpers
|
||||
- thesis summary
|
||||
- risks and opportunities extraction
|
||||
- structured panel generation
|
||||
- Storage and export
|
||||
- save note
|
||||
- save report
|
||||
- list saved artifacts
|
||||
- Restricted file actions
|
||||
- write within approved app directories only
|
||||
|
||||
The tool router should reject actions that fall outside the allowed scope instead of silently widening permissions.
|
||||
|
||||
### 5. Background Job Coordinator
|
||||
|
||||
Non-interactive workflows should not share the same execution path as simple interactive turns.
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- Queue and run jobs independently from the active terminal session
|
||||
- Persist enough state to report progress and final output
|
||||
- Emit progress events back to the UI
|
||||
- Support cancellation where practical
|
||||
|
||||
Representative background jobs:
|
||||
|
||||
- scheduled market news scans
|
||||
- watchlist brief generation
|
||||
- multi-symbol analysis batches
|
||||
- report compilation
|
||||
|
||||
### 6. Artifact and Storage Layer
|
||||
|
||||
The harness should distinguish between agent outputs that are transient and outputs that become durable user artifacts.
|
||||
|
||||
Durable artifacts may include:
|
||||
|
||||
- research notes
|
||||
- generated summaries
|
||||
- report exports
|
||||
- cached workflow outputs
|
||||
|
||||
The storage layer should define approved directories and filename rules before any write-capable tool is enabled.
|
||||
|
||||
## Interaction Model
|
||||
|
||||
### Interactive Flow
|
||||
|
||||
1. User submits a prompt from the terminal UI.
|
||||
2. React sends the prompt and workspace context through Tauri.
|
||||
3. Rust creates or resumes the workspace session.
|
||||
4. Rig evaluates whether it can answer directly or needs tools.
|
||||
5. Tools execute through the router.
|
||||
6. Rust emits progress and tool events back to the UI.
|
||||
7. Final output is returned as either:
|
||||
- structured panel payload
|
||||
- textual response
|
||||
- approval request
|
||||
|
||||
### Background Flow
|
||||
|
||||
1. User triggers a job directly or indirectly through an agent request.
|
||||
2. The job coordinator persists job metadata and starts execution.
|
||||
3. The coordinator emits progress updates.
|
||||
4. The final result is stored and surfaced to the UI.
|
||||
5. The UI can render the result immediately or expose it from history/artifacts later.
|
||||
|
||||
## Session Model
|
||||
|
||||
Sessions should be tied to MosaicIQ workspaces or tabs.
|
||||
|
||||
Each session should track:
|
||||
|
||||
- conversation history relevant to the workspace
|
||||
- last structured outputs
|
||||
- pending approvals
|
||||
- active jobs launched from the workspace
|
||||
- tool execution summaries where needed for continuity
|
||||
|
||||
This keeps agent context aligned with the current UI model instead of introducing a separate session concept that the user cannot see.
|
||||
|
||||
## Structured Output Strategy
|
||||
|
||||
The agent should default to structured outputs whenever a result maps to a known UI concept.
|
||||
|
||||
Expected output families:
|
||||
|
||||
- `company`
|
||||
- `portfolio`
|
||||
- `news`
|
||||
- `analysis`
|
||||
- `report`
|
||||
- `job_status`
|
||||
- `text`
|
||||
- `approval_request`
|
||||
|
||||
This preserves a clean contract between orchestration and rendering and avoids making the frontend parse narrative prose.
|
||||
|
||||
## Security and Permission Boundaries
|
||||
|
||||
The initial harness should use the narrowest viable permission model.
|
||||
|
||||
Defaults:
|
||||
|
||||
- No unrestricted shell access
|
||||
- No arbitrary filesystem traversal
|
||||
- No write access outside explicitly approved artifact paths
|
||||
- No silent execution of destructive actions
|
||||
- Restricted actions require an approval round-trip from the UI
|
||||
|
||||
This is a major reason not to lead with Pi RPC for the product runtime. Pi's strengths are strongest when broad tool access is desirable, but broad tool access is not the correct default for MosaicIQ at this stage.
|
||||
|
||||
## Why Not Pi RPC As The Default
|
||||
|
||||
Pi RPC mode is attractive when the core problem is open-ended tool execution, file editing, shell automation, or external process orchestration. That is not the current center of gravity for MosaicIQ.
|
||||
|
||||
For this project, choosing Pi RPC now would introduce:
|
||||
|
||||
- a second runtime to ship and supervise
|
||||
- a more complex protocol boundary than the current app needs
|
||||
- additional packaging and lifecycle concerns inside a Tauri desktop app
|
||||
- pressure to expose a broader tool surface earlier than is desirable
|
||||
|
||||
Pi should only be reconsidered if MosaicIQ grows a clearly separate power-user mode that depends on:
|
||||
|
||||
- arbitrary workspace editing
|
||||
- shell-first automation
|
||||
- long-lived interactive repair or refactor loops
|
||||
- strict process isolation from the main app runtime
|
||||
|
||||
If that happens, Pi should be integrated as a specialized sidecar rather than replacing the embedded Rig harness.
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1: Move the Agent Boundary to Rust
|
||||
|
||||
Outcome:
|
||||
|
||||
- React becomes a thin client
|
||||
- Rust owns prompt handling
|
||||
- the existing mock-driven flows still work through a real Tauri command boundary
|
||||
|
||||
### Phase 2: Add Rig-Orchestrated Structured Responses
|
||||
|
||||
Outcome:
|
||||
|
||||
- Rig produces typed outputs for existing panels
|
||||
- prompt and tool orchestration moves into Rust
|
||||
- frontend intent heuristics are retired as the source of truth
|
||||
|
||||
### Phase 3: Add Tool Routing and Approval Flow
|
||||
|
||||
Outcome:
|
||||
|
||||
- finance, news, storage, and export tools are introduced
|
||||
- restricted actions require approval
|
||||
- failure paths are explicit and user-visible
|
||||
|
||||
### Phase 4: Add Background Workflows
|
||||
|
||||
Outcome:
|
||||
|
||||
- news scans and report generation run as jobs
|
||||
- jobs expose progress, completion, and failure state
|
||||
- work can continue without blocking the terminal interaction path
|
||||
|
||||
### Phase 5: Add Bounded File Manipulation
|
||||
|
||||
Outcome:
|
||||
|
||||
- the agent can create and update app-managed artifacts
|
||||
- writes are constrained to approved locations
|
||||
- arbitrary editing remains out of scope unless future product needs justify it
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
The architecture should be considered correctly implemented when all of the following are true:
|
||||
|
||||
- Interactive prompts flow through Rust rather than frontend-only intent matching.
|
||||
- Existing panel-oriented experiences can be returned as structured outputs.
|
||||
- Background workflows have a separate lifecycle from interactive turns.
|
||||
- Restricted writes require explicit approval.
|
||||
- Session state stays isolated per workspace/tab.
|
||||
- Provider or tool failures do not corrupt session state and are surfaced clearly.
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
The implementation should be validated with at least these scenarios:
|
||||
|
||||
- company lookup request returns a structured company panel payload
|
||||
- news request can stream progress and complete with a structured news payload
|
||||
- analysis request can fall back gracefully when a provider or tool fails
|
||||
- a background scan job starts, reports progress, completes, and can be queried later
|
||||
- a write-capable tool is denied outside approved directories
|
||||
- one workspace session does not leak outputs or tool state into another
|
||||
|
||||
## Defaults and Assumptions
|
||||
|
||||
- Rig is the primary in-process agent runtime.
|
||||
- Tauri remains the only required shipped runtime.
|
||||
- File manipulation is limited to app-managed artifacts in the first version.
|
||||
- The harness is product-specific, not a general-purpose coding agent.
|
||||
- Pi RPC remains an optional future sidecar pattern, not the base architecture.
|
||||
|
||||
14
MosaicIQ/index.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Tauri + React + Typescript</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
29
MosaicIQ/package.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "mosaiciq",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"@tauri-apps/plugin-store": "~2",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"tailwindcss": "^4.2.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@vitejs/plugin-react": "^4.6.0",
|
||||
"typescript": "~5.8.3",
|
||||
"vite": "^7.0.4",
|
||||
"@tauri-apps/cli": "^2"
|
||||
}
|
||||
}
|
||||
6
MosaicIQ/public/tauri.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg width="206" height="231" viewBox="0 0 206 231" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M143.143 84C143.143 96.1503 133.293 106 121.143 106C108.992 106 99.1426 96.1503 99.1426 84C99.1426 71.8497 108.992 62 121.143 62C133.293 62 143.143 71.8497 143.143 84Z" fill="#FFC131"/>
|
||||
<ellipse cx="84.1426" cy="147" rx="22" ry="22" transform="rotate(180 84.1426 147)" fill="#24C8DB"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M166.738 154.548C157.86 160.286 148.023 164.269 137.757 166.341C139.858 160.282 141 153.774 141 147C141 144.543 140.85 142.121 140.558 139.743C144.975 138.204 149.215 136.139 153.183 133.575C162.73 127.404 170.292 118.608 174.961 108.244C179.63 97.8797 181.207 86.3876 179.502 75.1487C177.798 63.9098 172.884 53.4021 165.352 44.8883C157.82 36.3744 147.99 30.2165 137.042 27.1546C126.095 24.0926 114.496 24.2568 103.64 27.6274C92.7839 30.998 83.1319 37.4317 75.8437 46.1553C74.9102 47.2727 74.0206 48.4216 73.176 49.5993C61.9292 50.8488 51.0363 54.0318 40.9629 58.9556C44.2417 48.4586 49.5653 38.6591 56.679 30.1442C67.0505 17.7298 80.7861 8.57426 96.2354 3.77762C111.685 -1.01901 128.19 -1.25267 143.769 3.10474C159.348 7.46215 173.337 16.2252 184.056 28.3411C194.775 40.457 201.767 55.4101 204.193 71.404C206.619 87.3978 204.374 103.752 197.73 118.501C191.086 133.25 180.324 145.767 166.738 154.548ZM41.9631 74.275L62.5557 76.8042C63.0459 72.813 63.9401 68.9018 65.2138 65.1274C57.0465 67.0016 49.2088 70.087 41.9631 74.275Z" fill="#FFC131"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M38.4045 76.4519C47.3493 70.6709 57.2677 66.6712 67.6171 64.6132C65.2774 70.9669 64 77.8343 64 85.0001C64 87.1434 64.1143 89.26 64.3371 91.3442C60.0093 92.8732 55.8533 94.9092 51.9599 97.4256C42.4128 103.596 34.8505 112.392 30.1816 122.756C25.5126 133.12 23.9357 144.612 25.6403 155.851C27.3449 167.09 32.2584 177.598 39.7906 186.112C47.3227 194.626 57.153 200.784 68.1003 203.846C79.0476 206.907 90.6462 206.743 101.502 203.373C112.359 200.002 122.011 193.568 129.299 184.845C130.237 183.722 131.131 182.567 131.979 181.383C143.235 180.114 154.132 176.91 164.205 171.962C160.929 182.49 155.596 192.319 148.464 200.856C138.092 213.27 124.357 222.426 108.907 227.222C93.458 232.019 76.9524 232.253 61.3736 227.895C45.7948 223.538 31.8055 214.775 21.0867 202.659C10.3679 190.543 3.37557 175.59 0.949823 159.596C-1.47592 143.602 0.768139 127.248 7.41237 112.499C14.0566 97.7497 24.8183 85.2327 38.4045 76.4519ZM163.062 156.711L163.062 156.711C162.954 156.773 162.846 156.835 162.738 156.897C162.846 156.835 162.954 156.773 163.062 156.711Z" fill="#24C8DB"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
1
MosaicIQ/public/vite.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
7
MosaicIQ/src-tauri/.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
|
||||
# Generated by Tauri
|
||||
# will have schema files for capabilities auto-completion
|
||||
/gen/schemas
|
||||
6201
MosaicIQ/src-tauri/Cargo.lock
generated
Normal file
27
MosaicIQ/src-tauri/Cargo.toml
Normal file
@@ -0,0 +1,27 @@
|
||||
[package]
|
||||
name = "mosaiciq"
|
||||
version = "0.1.0"
|
||||
description = "A Tauri App"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[lib]
|
||||
# The `_lib` suffix may seem redundant but it is necessary
|
||||
# to make the lib name unique and wouldn't conflict with the bin name.
|
||||
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
|
||||
name = "mosaiciq_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-opener = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
rig-core = "0.34.0"
|
||||
tauri-plugin-store = "2"
|
||||
tokio = { version = "1", features = ["time"] }
|
||||
3
MosaicIQ/src-tauri/build.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
13
MosaicIQ/src-tauri/capabilities/default.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Capability for the main window",
|
||||
"windows": [
|
||||
"main"
|
||||
],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"opener:default",
|
||||
"store:default"
|
||||
]
|
||||
}
|
||||
BIN
MosaicIQ/src-tauri/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
MosaicIQ/src-tauri/icons/128x128@2x.png
Normal file
|
After Width: | Height: | Size: 6.8 KiB |
BIN
MosaicIQ/src-tauri/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 974 B |
BIN
MosaicIQ/src-tauri/icons/Square107x107Logo.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
MosaicIQ/src-tauri/icons/Square142x142Logo.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
MosaicIQ/src-tauri/icons/Square150x150Logo.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
MosaicIQ/src-tauri/icons/Square284x284Logo.png
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
BIN
MosaicIQ/src-tauri/icons/Square30x30Logo.png
Normal file
|
After Width: | Height: | Size: 903 B |
BIN
MosaicIQ/src-tauri/icons/Square310x310Logo.png
Normal file
|
After Width: | Height: | Size: 8.4 KiB |
BIN
MosaicIQ/src-tauri/icons/Square44x44Logo.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
MosaicIQ/src-tauri/icons/Square71x71Logo.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
MosaicIQ/src-tauri/icons/Square89x89Logo.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
MosaicIQ/src-tauri/icons/StoreLogo.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
MosaicIQ/src-tauri/icons/icon.icns
Normal file
BIN
MosaicIQ/src-tauri/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 85 KiB |
BIN
MosaicIQ/src-tauri/icons/icon.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
10
MosaicIQ/src-tauri/src/agent/mod.rs
Normal file
@@ -0,0 +1,10 @@
|
||||
//! Agent domain logic and request/response types.
|
||||
|
||||
mod service;
|
||||
mod types;
|
||||
|
||||
pub use service::AgentService;
|
||||
pub use types::{
|
||||
AgentDeltaEvent, AgentErrorEvent, AgentResultEvent, ChatPromptRequest, ChatStreamStart,
|
||||
PreparedChatTurn,
|
||||
};
|
||||
122
MosaicIQ/src-tauri/src/agent/service.rs
Normal file
@@ -0,0 +1,122 @@
|
||||
//! In-process agent service implementation.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::agent::{ChatPromptRequest, PreparedChatTurn};
|
||||
use crate::error::AppError;
|
||||
|
||||
/// Maintains prompt history per session for the in-process backend agent.
|
||||
#[derive(Default)]
|
||||
pub struct AgentService {
|
||||
sessions: HashMap<String, Vec<String>>,
|
||||
next_session_id: u64,
|
||||
}
|
||||
|
||||
impl AgentService {
|
||||
/// Validates an incoming prompt, appends it to the session history, and
|
||||
/// prepares the reply content for the streaming bridge.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`AppError::EmptyPrompt`] when the request does not include a
|
||||
/// non-whitespace prompt.
|
||||
pub fn prepare_turn(&mut self, request: ChatPromptRequest) -> Result<PreparedChatTurn, AppError> {
|
||||
let prompt = request.prompt.trim();
|
||||
if prompt.is_empty() {
|
||||
return Err(AppError::EmptyPrompt);
|
||||
}
|
||||
|
||||
let session_id = request.session_id.unwrap_or_else(|| {
|
||||
self.next_session_id += 1;
|
||||
format!("session-{}", self.next_session_id)
|
||||
});
|
||||
|
||||
// Persist session-local history now so future implementations can build
|
||||
// context without changing the command contract.
|
||||
let history = self.sessions.entry(session_id.clone()).or_default();
|
||||
history.push(prompt.to_string());
|
||||
let history_length = history.len();
|
||||
|
||||
Ok(PreparedChatTurn {
|
||||
workspace_id: request.workspace_id,
|
||||
session_id,
|
||||
prompt: prompt.to_string(),
|
||||
reply: build_reply(prompt, history_length),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn build_reply(prompt: &str, history_length: usize) -> String {
|
||||
if history_length == 1 {
|
||||
return format!(
|
||||
"Backend agent received: {prompt}\n\nStreaming is now active for plain-text chat. Ask a follow-up question to continue this workspace session."
|
||||
);
|
||||
}
|
||||
|
||||
format!(
|
||||
"Backend agent received: {prompt}\n\nContinuing the existing workspace conversation. This is turn {history_length} in the current session."
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::AgentService;
|
||||
use crate::agent::ChatPromptRequest;
|
||||
use crate::error::AppError;
|
||||
|
||||
mod prepare_turn {
|
||||
use super::{AgentService, AppError, ChatPromptRequest};
|
||||
|
||||
#[test]
|
||||
fn returns_empty_prompt_error_when_request_contains_only_whitespace() {
|
||||
let mut service = AgentService::default();
|
||||
|
||||
let result = service.prepare_turn(ChatPromptRequest {
|
||||
workspace_id: "workspace-1".to_string(),
|
||||
session_id: None,
|
||||
prompt: " ".to_string(),
|
||||
});
|
||||
|
||||
assert_eq!(result.unwrap_err(), AppError::EmptyPrompt);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creates_new_session_when_request_does_not_provide_one() {
|
||||
let mut service = AgentService::default();
|
||||
|
||||
let result = service
|
||||
.prepare_turn(ChatPromptRequest {
|
||||
workspace_id: "workspace-1".to_string(),
|
||||
session_id: None,
|
||||
prompt: "Summarize AAPL".to_string(),
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.session_id, "session-1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn increments_history_length_when_request_reuses_existing_session() {
|
||||
let mut service = AgentService::default();
|
||||
let session_id = "session-42".to_string();
|
||||
|
||||
let _ = service
|
||||
.prepare_turn(ChatPromptRequest {
|
||||
workspace_id: "workspace-1".to_string(),
|
||||
session_id: Some(session_id.clone()),
|
||||
prompt: "First prompt".to_string(),
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let result = service
|
||||
.prepare_turn(ChatPromptRequest {
|
||||
workspace_id: "workspace-1".to_string(),
|
||||
session_id: Some(session_id),
|
||||
prompt: "Second prompt".to_string(),
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert!(result.reply.contains("turn 2"));
|
||||
}
|
||||
}
|
||||
}
|
||||
78
MosaicIQ/src-tauri/src/agent/types.rs
Normal file
@@ -0,0 +1,78 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Request payload for an interactive chat turn.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ChatPromptRequest {
|
||||
/// Workspace identifier associated with the request.
|
||||
pub workspace_id: String,
|
||||
/// Existing session identifier for a continued conversation.
|
||||
pub session_id: Option<String>,
|
||||
/// User-entered prompt content.
|
||||
pub prompt: String,
|
||||
}
|
||||
|
||||
/// Synchronous chat turn preparation result used by the streaming command.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PreparedChatTurn {
|
||||
/// Workspace identifier associated with the turn.
|
||||
pub workspace_id: String,
|
||||
/// Stable backend session reused across conversational turns.
|
||||
pub session_id: String,
|
||||
/// Prompt content after validation and normalization.
|
||||
pub prompt: String,
|
||||
/// Fully prepared reply text that will be chunked into stream events.
|
||||
pub reply: String,
|
||||
}
|
||||
|
||||
/// Immediate response returned when a chat stream starts.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ChatStreamStart {
|
||||
/// Correlation id for the in-flight stream.
|
||||
pub request_id: String,
|
||||
/// Session used for this request.
|
||||
pub session_id: String,
|
||||
}
|
||||
|
||||
/// Incremental delta emitted while the backend streams a reply.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AgentDeltaEvent {
|
||||
/// Workspace that originated the request.
|
||||
pub workspace_id: String,
|
||||
/// Correlation id matching the original stream request.
|
||||
pub request_id: String,
|
||||
/// Session used for this request.
|
||||
pub session_id: String,
|
||||
/// Incremental text delta to append in the UI.
|
||||
pub delta: String,
|
||||
}
|
||||
|
||||
/// Final reply emitted when the backend completes a stream.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AgentResultEvent {
|
||||
/// Workspace that originated the request.
|
||||
pub workspace_id: String,
|
||||
/// Correlation id matching the original stream request.
|
||||
pub request_id: String,
|
||||
/// Session used for this request.
|
||||
pub session_id: String,
|
||||
/// Final reply content for the completed stream.
|
||||
pub reply: String,
|
||||
}
|
||||
|
||||
/// Error event emitted when the backend cannot complete a stream.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AgentErrorEvent {
|
||||
/// Workspace that originated the request.
|
||||
pub workspace_id: String,
|
||||
/// Correlation id matching the original stream request.
|
||||
pub request_id: String,
|
||||
/// Session used for this request.
|
||||
pub session_id: String,
|
||||
/// User-visible error message for the failed stream.
|
||||
pub message: String,
|
||||
}
|
||||
22
MosaicIQ/src-tauri/src/commands/agent.rs
Normal file
@@ -0,0 +1,22 @@
|
||||
use crate::agent::{AgentPromptRequest, AgentPromptResponse};
|
||||
use crate::state::AppState;
|
||||
|
||||
/// Handles interactive agent prompts from the frontend.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error string when shared backend state is unavailable or when
|
||||
/// the request fails validation in the agent layer.
|
||||
#[tauri::command]
|
||||
pub async fn agent_prompt(
|
||||
state: tauri::State<'_, AppState>,
|
||||
request: AgentPromptRequest,
|
||||
) -> Result<AgentPromptResponse, String> {
|
||||
// Convert a poisoned mutex into a user-visible error instead of panicking.
|
||||
let mut agent = state
|
||||
.agent
|
||||
.lock()
|
||||
.map_err(|_| "agent state is unavailable".to_string())?;
|
||||
|
||||
agent.prompt(request).map_err(|error| error.to_string())
|
||||
}
|
||||
3
MosaicIQ/src-tauri/src/commands/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
//! Tauri command handlers.
|
||||
|
||||
pub mod terminal;
|
||||
19
MosaicIQ/src-tauri/src/commands/search.rs
Normal file
@@ -0,0 +1,19 @@
|
||||
/// Handles the search command from the frontend, which performs a search query against both yahoo finance and the sec api
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error string when shared backend state is unavailable or when
|
||||
/// the request fails validation in the agent layer.
|
||||
#[tauri::command]
|
||||
pub async fn search_ticker(
|
||||
state: tauri::State<'_, AppState>,
|
||||
request: AgentPromptRequest,
|
||||
) -> Result<AgentPromptResponse, String> {
|
||||
// Convert a poisoned mutex into a user-visible error instead of panicking.
|
||||
let mut agent = state
|
||||
.agent
|
||||
.lock()
|
||||
.map_err(|_| "agent state is unavailable".to_string())?;
|
||||
|
||||
agent.prompt(request).map_err(|error| error.to_string())
|
||||
}
|
||||
110
MosaicIQ/src-tauri/src/commands/terminal.rs
Normal file
@@ -0,0 +1,110 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use tauri::Emitter;
|
||||
|
||||
use crate::agent::{
|
||||
AgentDeltaEvent, AgentErrorEvent, AgentResultEvent, ChatPromptRequest, ChatStreamStart,
|
||||
};
|
||||
use crate::state::AppState;
|
||||
use crate::terminal::{ExecuteTerminalCommandRequest, TerminalCommandResponse};
|
||||
|
||||
/// Executes a slash command and returns either terminal text or a structured panel payload.
|
||||
#[tauri::command]
|
||||
pub async fn execute_terminal_command(
|
||||
state: tauri::State<'_, AppState>,
|
||||
request: ExecuteTerminalCommandRequest,
|
||||
) -> Result<TerminalCommandResponse, String> {
|
||||
Ok(state.command_service.execute(request))
|
||||
}
|
||||
|
||||
/// Starts a streaming plain-text chat turn and emits progress over Tauri events.
|
||||
#[tauri::command]
|
||||
pub async fn start_chat_stream(
|
||||
app: tauri::AppHandle,
|
||||
state: tauri::State<'_, AppState>,
|
||||
request: ChatPromptRequest,
|
||||
) -> Result<ChatStreamStart, String> {
|
||||
let request_id = state.next_request_id();
|
||||
let prepared_turn = {
|
||||
let mut agent = state
|
||||
.agent
|
||||
.lock()
|
||||
.map_err(|_| "agent state is unavailable".to_string())?;
|
||||
|
||||
agent
|
||||
.prepare_turn(request)
|
||||
.map_err(|error| error.to_string())?
|
||||
};
|
||||
|
||||
let start = ChatStreamStart {
|
||||
request_id: request_id.clone(),
|
||||
session_id: prepared_turn.session_id.clone(),
|
||||
};
|
||||
|
||||
let workspace_id = prepared_turn.workspace_id.clone();
|
||||
let session_id = prepared_turn.session_id.clone();
|
||||
let reply = prepared_turn.reply.clone();
|
||||
let should_fail = prepared_turn.prompt.contains("__simulate_stream_error__");
|
||||
|
||||
tauri::async_runtime::spawn(async move {
|
||||
// Delay the first event slightly so the frontend can register callbacks for the new request id.
|
||||
tokio::time::sleep(Duration::from_millis(30)).await;
|
||||
|
||||
if should_fail {
|
||||
let _ = app.emit(
|
||||
"agent_error",
|
||||
AgentErrorEvent {
|
||||
workspace_id,
|
||||
request_id,
|
||||
session_id,
|
||||
message: "Simulated backend stream failure.".to_string(),
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Emit coarse-grained deltas for now; the event contract remains stable when a real model streams tokens.
|
||||
for chunk in chunk_reply(&reply) {
|
||||
let _ = app.emit(
|
||||
"agent_delta",
|
||||
AgentDeltaEvent {
|
||||
workspace_id: workspace_id.clone(),
|
||||
request_id: request_id.clone(),
|
||||
session_id: session_id.clone(),
|
||||
delta: chunk,
|
||||
},
|
||||
);
|
||||
tokio::time::sleep(Duration::from_millis(60)).await;
|
||||
}
|
||||
|
||||
let _ = app.emit(
|
||||
"agent_result",
|
||||
AgentResultEvent {
|
||||
workspace_id,
|
||||
request_id,
|
||||
session_id,
|
||||
reply,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
Ok(start)
|
||||
}
|
||||
|
||||
/// Splits a reply into small word groups to simulate incremental streaming.
|
||||
fn chunk_reply(reply: &str) -> Vec<String> {
|
||||
let words = reply.split_whitespace().collect::<Vec<_>>();
|
||||
|
||||
if words.is_empty() {
|
||||
return vec![String::new()];
|
||||
}
|
||||
|
||||
words
|
||||
.chunks(3)
|
||||
.map(|chunk| {
|
||||
let mut delta = chunk.join(" ");
|
||||
delta.push(' ');
|
||||
delta
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
18
MosaicIQ/src-tauri/src/error.rs
Normal file
@@ -0,0 +1,18 @@
|
||||
use std::error::Error;
|
||||
use std::fmt::{Display, Formatter};
|
||||
|
||||
/// Backend error type for application-level validation failures.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum AppError {
|
||||
EmptyPrompt,
|
||||
}
|
||||
|
||||
impl Display for AppError {
|
||||
fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::EmptyPrompt => formatter.write_str("prompt cannot be empty"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for AppError {}
|
||||
27
MosaicIQ/src-tauri/src/lib.rs
Normal file
@@ -0,0 +1,27 @@
|
||||
//! Tauri backend for MosaicIQ.
|
||||
//!
|
||||
//! The backend is intentionally split into a small bridge layer (`commands`),
|
||||
//! domain logic (`agent`), and shared runtime state (`state`) so UI-facing
|
||||
//! commands do not accumulate business logic over time.
|
||||
|
||||
mod agent;
|
||||
mod commands;
|
||||
mod error;
|
||||
mod state;
|
||||
mod terminal;
|
||||
|
||||
/// Starts the Tauri application and registers the backend command surface.
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
// Keep shared backend services in managed state so commands stay thin.
|
||||
.manage(state::AppState::default())
|
||||
.plugin(tauri_plugin_store::Builder::new().build())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
commands::terminal::execute_terminal_command,
|
||||
commands::terminal::start_chat_stream
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
6
MosaicIQ/src-tauri/src/main.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
mosaiciq_lib::run()
|
||||
}
|
||||
34
MosaicIQ/src-tauri/src/state.rs
Normal file
@@ -0,0 +1,34 @@
|
||||
//! Shared application state managed by Tauri.
|
||||
|
||||
use std::sync::Mutex;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use crate::agent::AgentService;
|
||||
use crate::terminal::TerminalCommandService;
|
||||
|
||||
/// Runtime services shared across Tauri commands.
|
||||
pub struct AppState {
|
||||
/// Stateful chat service used for per-session conversation history.
|
||||
pub agent: Mutex<AgentService>,
|
||||
/// Slash-command executor backed by shared mock data.
|
||||
pub command_service: TerminalCommandService,
|
||||
next_request_id: AtomicU64,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
/// Generates a unique request id for correlating stream events with frontend listeners.
|
||||
pub fn next_request_id(&self) -> String {
|
||||
let id = self.next_request_id.fetch_add(1, Ordering::Relaxed);
|
||||
format!("request-{id}")
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AppState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
agent: Mutex::new(AgentService::default()),
|
||||
command_service: TerminalCommandService::default(),
|
||||
next_request_id: AtomicU64::new(1),
|
||||
}
|
||||
}
|
||||
}
|
||||
227
MosaicIQ/src-tauri/src/terminal/command_service.rs
Normal file
@@ -0,0 +1,227 @@
|
||||
use crate::terminal::mock_data::load_mock_financial_data;
|
||||
use crate::terminal::{
|
||||
ChatCommandRequest, Company, ExecuteTerminalCommandRequest, MockFinancialData, PanelPayload,
|
||||
TerminalCommandResponse,
|
||||
};
|
||||
|
||||
/// Executes supported slash commands against the shared mock financial dataset.
|
||||
pub struct TerminalCommandService {
|
||||
mock_data: MockFinancialData,
|
||||
}
|
||||
|
||||
impl Default for TerminalCommandService {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
mock_data: load_mock_financial_data(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TerminalCommandService {
|
||||
/// Resolves a slash command into either a text reply or a structured panel payload.
|
||||
pub fn execute(&self, request: ExecuteTerminalCommandRequest) -> TerminalCommandResponse {
|
||||
let command = parse_command(&request.input);
|
||||
|
||||
match command.command.as_str() {
|
||||
"/search" => self.search(command.args.join(" ").trim()),
|
||||
"/portfolio" => TerminalCommandResponse::Panel {
|
||||
panel: PanelPayload::Portfolio {
|
||||
data: self.mock_data.portfolio.clone(),
|
||||
},
|
||||
},
|
||||
"/news" => self.news(command.args.first().map(String::as_str)),
|
||||
"/analyze" => self.analyze(command.args.first().map(String::as_str)),
|
||||
"/help" => help_response(),
|
||||
_ => TerminalCommandResponse::Text {
|
||||
content: format!(
|
||||
"Unknown command: {}\n\n{}",
|
||||
command.command,
|
||||
help_text()
|
||||
),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn search(&self, query: &str) -> TerminalCommandResponse {
|
||||
if query.is_empty() {
|
||||
return TerminalCommandResponse::Text {
|
||||
content: "Usage: /search [ticker or company name]".to_string(),
|
||||
};
|
||||
}
|
||||
|
||||
if let Some(company) = self.find_company_by_symbol(query) {
|
||||
return TerminalCommandResponse::Panel {
|
||||
panel: PanelPayload::Company { data: company },
|
||||
};
|
||||
}
|
||||
|
||||
let matches = self.search_companies(query);
|
||||
if matches.is_empty() {
|
||||
return TerminalCommandResponse::Text {
|
||||
content: format!("No results found for \"{query}\"."),
|
||||
};
|
||||
}
|
||||
|
||||
let lines = matches
|
||||
.iter()
|
||||
.map(|company| format!(" {} {}", company.symbol, company.name))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
TerminalCommandResponse::Text {
|
||||
content: format!("Multiple matches found for \"{query}\":\n{lines}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn news(&self, ticker: Option<&str>) -> TerminalCommandResponse {
|
||||
let normalized_ticker = ticker.map(|value| value.trim().to_uppercase());
|
||||
let news_items = match normalized_ticker.as_deref() {
|
||||
Some(ticker) if !ticker.is_empty() => self
|
||||
.mock_data
|
||||
.news_items
|
||||
.iter()
|
||||
.filter(|item| {
|
||||
item.related_tickers
|
||||
.iter()
|
||||
.any(|related| related.eq_ignore_ascii_case(ticker))
|
||||
})
|
||||
.cloned()
|
||||
.collect(),
|
||||
_ => self.mock_data.news_items.clone(),
|
||||
};
|
||||
|
||||
TerminalCommandResponse::Panel {
|
||||
panel: PanelPayload::News {
|
||||
data: news_items,
|
||||
ticker: normalized_ticker.filter(|value| !value.is_empty()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn analyze(&self, ticker: Option<&str>) -> TerminalCommandResponse {
|
||||
let Some(ticker) = ticker.map(str::trim).filter(|value| !value.is_empty()) else {
|
||||
return TerminalCommandResponse::Text {
|
||||
content: "Usage: /analyze [ticker]".to_string(),
|
||||
};
|
||||
};
|
||||
|
||||
match self.mock_data.analyses.get(&ticker.to_uppercase()) {
|
||||
Some(analysis) => TerminalCommandResponse::Panel {
|
||||
panel: PanelPayload::Analysis {
|
||||
data: analysis.clone(),
|
||||
},
|
||||
},
|
||||
None => TerminalCommandResponse::Text {
|
||||
content: format!("Analysis not available for \"{ticker}\"."),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn find_company_by_symbol(&self, query: &str) -> Option<Company> {
|
||||
self.mock_data
|
||||
.companies
|
||||
.iter()
|
||||
.find(|company| company.symbol.eq_ignore_ascii_case(query))
|
||||
.cloned()
|
||||
}
|
||||
|
||||
fn search_companies(&self, query: &str) -> Vec<Company> {
|
||||
let normalized_query = query.to_lowercase();
|
||||
|
||||
self.mock_data
|
||||
.companies
|
||||
.iter()
|
||||
.filter(|company| {
|
||||
company
|
||||
.symbol
|
||||
.to_lowercase()
|
||||
.contains(&normalized_query)
|
||||
|| company.name.to_lowercase().contains(&normalized_query)
|
||||
})
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses raw slash-command input into a normalized command plus positional arguments.
|
||||
fn parse_command(input: &str) -> ChatCommandRequest {
|
||||
let trimmed = input.trim();
|
||||
let mut parts = trimmed.split_whitespace();
|
||||
let command = parts.next().unwrap_or_default().to_ascii_lowercase();
|
||||
let args = parts.map(ToString::to_string).collect();
|
||||
|
||||
ChatCommandRequest {
|
||||
command,
|
||||
args,
|
||||
raw_input: trimmed.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Human-readable help text returned for `/help` and unknown commands.
|
||||
fn help_text() -> &'static str {
|
||||
"Available Commands:\n\n /search [ticker] - Search for a stock\n /portfolio - Show your portfolio\n /news [ticker?] - Show market news\n /analyze [ticker] - Get stock analysis\n /help - Show this help text\n /clear - Clear terminal locally"
|
||||
}
|
||||
|
||||
/// Wraps the shared help text into the terminal command response envelope.
|
||||
fn help_response() -> TerminalCommandResponse {
|
||||
TerminalCommandResponse::Text {
|
||||
content: help_text().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::TerminalCommandService;
|
||||
use crate::terminal::{ExecuteTerminalCommandRequest, PanelPayload, TerminalCommandResponse};
|
||||
|
||||
#[test]
|
||||
fn returns_company_panel_for_exact_search_match() {
|
||||
let service = TerminalCommandService::default();
|
||||
|
||||
let response = service.execute(ExecuteTerminalCommandRequest {
|
||||
workspace_id: "workspace-1".to_string(),
|
||||
input: "/search AAPL".to_string(),
|
||||
});
|
||||
|
||||
match response {
|
||||
TerminalCommandResponse::Panel {
|
||||
panel: PanelPayload::Company { data },
|
||||
} => assert_eq!(data.symbol, "AAPL"),
|
||||
other => panic!("expected company panel, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_text_for_unknown_command() {
|
||||
let service = TerminalCommandService::default();
|
||||
|
||||
let response = service.execute(ExecuteTerminalCommandRequest {
|
||||
workspace_id: "workspace-1".to_string(),
|
||||
input: "/wat".to_string(),
|
||||
});
|
||||
|
||||
match response {
|
||||
TerminalCommandResponse::Text { content } => {
|
||||
assert!(content.contains("Unknown command"));
|
||||
}
|
||||
other => panic!("expected text response, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_usage_for_analyze_without_ticker() {
|
||||
let service = TerminalCommandService::default();
|
||||
|
||||
let response = service.execute(ExecuteTerminalCommandRequest {
|
||||
workspace_id: "workspace-1".to_string(),
|
||||
input: "/analyze".to_string(),
|
||||
});
|
||||
|
||||
match response {
|
||||
TerminalCommandResponse::Text { content } => {
|
||||
assert_eq!(content, "Usage: /analyze [ticker]");
|
||||
}
|
||||
other => panic!("expected text response, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
8
MosaicIQ/src-tauri/src/terminal/mock_data.rs
Normal file
@@ -0,0 +1,8 @@
|
||||
use crate::terminal::MockFinancialData;
|
||||
|
||||
const MOCK_FINANCIAL_DATA: &str = include_str!("../../../src/shared/mock-financial-data.json");
|
||||
|
||||
/// Loads the shared static financial fixture for backend slash-command execution.
|
||||
pub fn load_mock_financial_data() -> MockFinancialData {
|
||||
serde_json::from_str(MOCK_FINANCIAL_DATA).expect("mock financial data fixture should be valid")
|
||||
}
|
||||
9
MosaicIQ/src-tauri/src/terminal/mod.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
mod command_service;
|
||||
mod mock_data;
|
||||
mod types;
|
||||
|
||||
pub use command_service::TerminalCommandService;
|
||||
pub use types::{
|
||||
ChatCommandRequest, Company, ExecuteTerminalCommandRequest, MockFinancialData, PanelPayload,
|
||||
TerminalCommandResponse,
|
||||
};
|
||||
127
MosaicIQ/src-tauri/src/terminal/types.rs
Normal file
@@ -0,0 +1,127 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Frontend request payload for slash-command execution.
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ExecuteTerminalCommandRequest {
|
||||
/// Workspace that originated the command.
|
||||
pub workspace_id: String,
|
||||
/// Raw slash-command input exactly as entered by the user.
|
||||
pub input: String,
|
||||
}
|
||||
|
||||
/// Parsed slash command used internally by the backend command service.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ChatCommandRequest {
|
||||
/// Normalized command verb such as `/search`.
|
||||
pub command: String,
|
||||
/// Positional command arguments.
|
||||
pub args: Vec<String>,
|
||||
/// Original raw input for debugging or future auditing.
|
||||
pub raw_input: String,
|
||||
}
|
||||
|
||||
/// Backend response envelope for slash-command execution.
|
||||
#[derive(Debug, Clone, Serialize, PartialEq)]
|
||||
#[serde(tag = "kind", rename_all = "camelCase")]
|
||||
pub enum TerminalCommandResponse {
|
||||
/// Plain text response rendered directly in the terminal.
|
||||
Text { content: String },
|
||||
/// Structured payload rendered by an existing terminal panel.
|
||||
Panel { panel: PanelPayload },
|
||||
}
|
||||
|
||||
/// Serializable panel variants shared between Rust and React.
|
||||
#[derive(Debug, Clone, Serialize, PartialEq)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
pub enum PanelPayload {
|
||||
Company { data: Company },
|
||||
Portfolio { data: Portfolio },
|
||||
News {
|
||||
data: Vec<NewsItem>,
|
||||
ticker: Option<String>,
|
||||
},
|
||||
Analysis { data: StockAnalysis },
|
||||
}
|
||||
|
||||
/// Company snapshot used by the company panel.
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Company {
|
||||
pub symbol: String,
|
||||
pub name: String,
|
||||
pub price: f64,
|
||||
pub change: f64,
|
||||
pub change_percent: f64,
|
||||
pub market_cap: f64,
|
||||
pub volume: u64,
|
||||
pub pe: Option<f64>,
|
||||
pub eps: Option<f64>,
|
||||
pub high52_week: Option<f64>,
|
||||
pub low52_week: Option<f64>,
|
||||
}
|
||||
|
||||
/// Portfolio holding row.
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Holding {
|
||||
pub symbol: String,
|
||||
pub name: String,
|
||||
pub quantity: u64,
|
||||
pub avg_cost: f64,
|
||||
pub current_price: f64,
|
||||
pub current_value: f64,
|
||||
pub gain_loss: f64,
|
||||
pub gain_loss_percent: f64,
|
||||
}
|
||||
|
||||
/// Portfolio summary and holdings data.
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Portfolio {
|
||||
pub holdings: Vec<Holding>,
|
||||
pub total_value: f64,
|
||||
pub day_change: f64,
|
||||
pub day_change_percent: f64,
|
||||
pub total_gain: f64,
|
||||
pub total_gain_percent: f64,
|
||||
}
|
||||
|
||||
/// News item serialized with an ISO timestamp for transport safety.
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NewsItem {
|
||||
pub id: String,
|
||||
pub source: String,
|
||||
pub headline: String,
|
||||
pub timestamp: String,
|
||||
pub snippet: String,
|
||||
pub url: Option<String>,
|
||||
pub related_tickers: Vec<String>,
|
||||
}
|
||||
|
||||
/// Structured analysis payload for the analysis panel.
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct StockAnalysis {
|
||||
pub symbol: String,
|
||||
pub summary: String,
|
||||
pub sentiment: String,
|
||||
pub key_points: Vec<String>,
|
||||
pub risks: Vec<String>,
|
||||
pub opportunities: Vec<String>,
|
||||
pub recommendation: String,
|
||||
pub target_price: Option<f64>,
|
||||
}
|
||||
|
||||
/// Shared mock financial fixture loaded by both the frontend and backend.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MockFinancialData {
|
||||
pub companies: Vec<Company>,
|
||||
pub portfolio: Portfolio,
|
||||
pub news_items: Vec<NewsItem>,
|
||||
pub analyses: HashMap<String, StockAnalysis>,
|
||||
}
|
||||
35
MosaicIQ/src-tauri/tauri.conf.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "mosaiciq",
|
||||
"version": "0.1.0",
|
||||
"identifier": "com.francescobrassesco.mosaiciq",
|
||||
"build": {
|
||||
"beforeDevCommand": "bun run dev",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeBuildCommand": "bun run build",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "mosaiciq",
|
||||
"width": 800,
|
||||
"height": 600
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
}
|
||||
}
|
||||
126
MosaicIQ/src/App.css
Normal file
@@ -0,0 +1,126 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600;700&display=swap');
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
/* Terminal Colors */
|
||||
--bg-primary: #0a0a0a;
|
||||
--bg-secondary: #111111;
|
||||
--bg-tertiary: #1a1a1a;
|
||||
|
||||
/* Text Colors */
|
||||
--text-primary: #e0e0e0;
|
||||
--text-secondary: #888888;
|
||||
--text-muted: #666666;
|
||||
|
||||
/* Accent Colors */
|
||||
--accent-green: #00d26a;
|
||||
--accent-red: #ff4757;
|
||||
--accent-blue: #58a6ff;
|
||||
--border-color: #2a2a2a;
|
||||
|
||||
/* Typography */
|
||||
--font-mono: 'IBM Plex Mono', 'JetBrains Mono', 'SF Mono', 'Monaco', 'Inconsolata', monospace;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: var(--font-mono);
|
||||
background-color: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
#root {
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Scrollbar Styling */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--border-color);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #3a3a3a;
|
||||
}
|
||||
|
||||
/* Utility Classes */
|
||||
.font-mono {
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.animate-in {
|
||||
animation: fadeIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Delay classes for staggered animations */
|
||||
.delay-75 {
|
||||
animation-delay: 75ms;
|
||||
}
|
||||
|
||||
.delay-150 {
|
||||
animation-delay: 150ms;
|
||||
}
|
||||
|
||||
.delay-200 {
|
||||
animation-delay: 200ms;
|
||||
}
|
||||
|
||||
/* Line clamp for text truncation */
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Selection color */
|
||||
::selection {
|
||||
background-color: var(--accent-blue);
|
||||
color: var(--bg-primary);
|
||||
}
|
||||
|
||||
/* Focus visible for accessibility */
|
||||
*:focus-visible {
|
||||
outline: 2px solid var(--accent-blue);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Tab animations */
|
||||
.tab-transition {
|
||||
transition: all 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
/* Custom width for sidebar (Tailwind w-70 equivalent) */
|
||||
.w-70 {
|
||||
width: 17.5rem; /* 280px */
|
||||
}
|
||||
|
||||
280
MosaicIQ/src/App.tsx
Normal file
@@ -0,0 +1,280 @@
|
||||
import React, { useEffect, useCallback, useRef } from 'react';
|
||||
import { Terminal } from './components/Terminal/Terminal';
|
||||
import { Sidebar } from './components/Sidebar/Sidebar';
|
||||
import { TabBar } from './components/TabBar/TabBar';
|
||||
import { useTabs } from './hooks/useTabs';
|
||||
import { createEntry } from './hooks/useTerminal';
|
||||
import { terminalBridge } from './lib/terminalBridge';
|
||||
import './App.css';
|
||||
|
||||
function App() {
|
||||
const tabs = useTabs();
|
||||
const [sidebarOpen, setSidebarOpen] = React.useState(true);
|
||||
const [isProcessing, setIsProcessing] = React.useState(false);
|
||||
const commandHistoryRefs = useRef<Record<string, string[]>>({});
|
||||
const commandIndexRefs = useRef<Record<string, number>>({});
|
||||
|
||||
const getActiveHistory = () => {
|
||||
return tabs.activeWorkspace?.history || [];
|
||||
};
|
||||
|
||||
const getActiveCommandHistory = () => {
|
||||
return commandHistoryRefs.current[tabs.activeWorkspaceId] || [];
|
||||
};
|
||||
|
||||
const pushCommandHistory = useCallback((workspaceId: string, command: string) => {
|
||||
if (!commandHistoryRefs.current[workspaceId]) {
|
||||
commandHistoryRefs.current[workspaceId] = [];
|
||||
}
|
||||
commandHistoryRefs.current[workspaceId].push(command);
|
||||
commandIndexRefs.current[workspaceId] = -1;
|
||||
}, []);
|
||||
|
||||
const clearWorkspaceSession = useCallback((workspaceId: string) => {
|
||||
tabs.clearWorkspace(workspaceId);
|
||||
tabs.setWorkspaceSession(workspaceId, undefined);
|
||||
commandIndexRefs.current[workspaceId] = -1;
|
||||
}, [tabs]);
|
||||
|
||||
const handleCommand = useCallback(async (command: string) => {
|
||||
const trimmedCommand = command.trim();
|
||||
const workspaceId = tabs.activeWorkspaceId;
|
||||
const currentWorkspace = tabs.workspaces.find(
|
||||
(workspace) => workspace.id === workspaceId,
|
||||
);
|
||||
const isSlashCommand = trimmedCommand.startsWith('/');
|
||||
|
||||
if (!trimmedCommand) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (trimmedCommand === '/clear' || trimmedCommand.toLowerCase() === 'clear') {
|
||||
clearWorkspaceSession(workspaceId);
|
||||
return;
|
||||
}
|
||||
|
||||
pushCommandHistory(workspaceId, trimmedCommand);
|
||||
setIsProcessing(true);
|
||||
|
||||
if (isSlashCommand) {
|
||||
// Slash commands intentionally reset the transcript and session before rendering a fresh result.
|
||||
const commandEntry = createEntry({ type: 'command', content: trimmedCommand });
|
||||
clearWorkspaceSession(workspaceId);
|
||||
tabs.appendWorkspaceEntry(workspaceId, commandEntry);
|
||||
|
||||
try {
|
||||
const response = await terminalBridge.executeTerminalCommand({
|
||||
workspaceId,
|
||||
input: trimmedCommand,
|
||||
});
|
||||
|
||||
tabs.appendWorkspaceEntry(
|
||||
workspaceId,
|
||||
createEntry(
|
||||
response.kind === 'text'
|
||||
? { type: 'response', content: response.content }
|
||||
: { type: 'panel', content: response.panel },
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
tabs.appendWorkspaceEntry(
|
||||
workspaceId,
|
||||
createEntry({
|
||||
type: 'error',
|
||||
content: error instanceof Error ? error.message : 'Command execution failed.',
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Plain text keeps the current workspace conversation alive and streams into a placeholder response entry.
|
||||
const commandEntry = createEntry({ type: 'command', content: trimmedCommand });
|
||||
const responseEntry = createEntry({ type: 'response', content: '' });
|
||||
|
||||
tabs.appendWorkspaceEntry(workspaceId, commandEntry);
|
||||
tabs.appendWorkspaceEntry(workspaceId, responseEntry);
|
||||
|
||||
try {
|
||||
const start = await terminalBridge.startChatStream(
|
||||
{
|
||||
workspaceId,
|
||||
sessionId: currentWorkspace?.chatSessionId,
|
||||
prompt: trimmedCommand,
|
||||
},
|
||||
{
|
||||
onDelta: (event) => {
|
||||
// Update only the originating entry so workspace switches do not disrupt the active stream.
|
||||
tabs.updateWorkspaceEntry(workspaceId, responseEntry.id, (entry) => ({
|
||||
...entry,
|
||||
content: typeof entry.content === 'string' ? `${entry.content}${event.delta}` : event.delta,
|
||||
timestamp: new Date(),
|
||||
}));
|
||||
},
|
||||
onResult: (event) => {
|
||||
tabs.setWorkspaceSession(workspaceId, event.sessionId);
|
||||
tabs.updateWorkspaceEntry(workspaceId, responseEntry.id, (entry) => ({
|
||||
...entry,
|
||||
type: 'response',
|
||||
content: event.reply,
|
||||
timestamp: new Date(),
|
||||
}));
|
||||
setIsProcessing(false);
|
||||
},
|
||||
onError: (event) => {
|
||||
tabs.updateWorkspaceEntry(workspaceId, responseEntry.id, (entry) => ({
|
||||
...entry,
|
||||
type: 'error',
|
||||
content: event.message,
|
||||
timestamp: new Date(),
|
||||
}));
|
||||
setIsProcessing(false);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
tabs.setWorkspaceSession(workspaceId, start.sessionId);
|
||||
} catch (error) {
|
||||
tabs.updateWorkspaceEntry(workspaceId, responseEntry.id, (entry) => ({
|
||||
...entry,
|
||||
type: 'error',
|
||||
content: error instanceof Error ? error.message : 'Chat stream failed.',
|
||||
timestamp: new Date(),
|
||||
}));
|
||||
setIsProcessing(false);
|
||||
}
|
||||
}, [tabs, clearWorkspaceSession, pushCommandHistory]);
|
||||
|
||||
// Command history navigation
|
||||
// Accesses from END of array (most recent commands first)
|
||||
// Index -1 = current input, 0 = most recent, 1 = second most recent, etc.
|
||||
const getPreviousCommand = useCallback(() => {
|
||||
const history = getActiveCommandHistory();
|
||||
const currentIndex = commandIndexRefs.current[tabs.activeWorkspaceId] || -1;
|
||||
|
||||
// Up arrow: go BACK in history (toward older commands)
|
||||
if (currentIndex < history.length - 1) {
|
||||
const newIndex = currentIndex + 1;
|
||||
commandIndexRefs.current[tabs.activeWorkspaceId] = newIndex;
|
||||
return history[history.length - 1 - newIndex];
|
||||
}
|
||||
return null;
|
||||
}, [tabs.activeWorkspaceId]);
|
||||
|
||||
const getNextCommand = useCallback(() => {
|
||||
const history = getActiveCommandHistory();
|
||||
const currentIndex = commandIndexRefs.current[tabs.activeWorkspaceId] || -1;
|
||||
|
||||
// Down arrow: go FORWARD in history (toward newer commands)
|
||||
if (currentIndex > 0) {
|
||||
const newIndex = currentIndex - 1;
|
||||
commandIndexRefs.current[tabs.activeWorkspaceId] = newIndex;
|
||||
return history[history.length - 1 - newIndex];
|
||||
} else if (currentIndex === 0) {
|
||||
// At most recent command, return to current input
|
||||
commandIndexRefs.current[tabs.activeWorkspaceId] = -1;
|
||||
return '';
|
||||
}
|
||||
return null;
|
||||
}, [tabs.activeWorkspaceId]);
|
||||
|
||||
const resetCommandIndex = useCallback(() => {
|
||||
commandIndexRefs.current[tabs.activeWorkspaceId] = -1;
|
||||
}, [tabs.activeWorkspaceId]);
|
||||
|
||||
const outputRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
tabs.workspaces.forEach((workspace) => {
|
||||
commandHistoryRefs.current[workspace.id] ??= [];
|
||||
commandIndexRefs.current[workspace.id] ??= -1;
|
||||
});
|
||||
}, [tabs.workspaces]);
|
||||
|
||||
const clearTerminal = useCallback(() => {
|
||||
clearWorkspaceSession(tabs.activeWorkspaceId);
|
||||
}, [clearWorkspaceSession, tabs.activeWorkspaceId]);
|
||||
|
||||
const handleCreateWorkspace = useCallback(() => {
|
||||
tabs.createWorkspace();
|
||||
}, [tabs]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 't') {
|
||||
e.preventDefault();
|
||||
handleCreateWorkspace();
|
||||
}
|
||||
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'w') {
|
||||
e.preventDefault();
|
||||
tabs.closeWorkspace(tabs.activeWorkspaceId);
|
||||
}
|
||||
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'b') {
|
||||
e.preventDefault();
|
||||
setSidebarOpen(prev => !prev);
|
||||
}
|
||||
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'l') {
|
||||
e.preventDefault();
|
||||
clearTerminal();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [tabs, clearTerminal, handleCreateWorkspace]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
// Release shared Tauri listeners when the app shell unmounts.
|
||||
void terminalBridge.dispose();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const tabBarTabs = tabs.workspaces.map(w => ({
|
||||
id: w.id,
|
||||
name: w.name,
|
||||
isActive: w.id === tabs.activeWorkspaceId
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-[#0a0a0a]">
|
||||
{/* Sidebar */}
|
||||
<Sidebar
|
||||
isOpen={sidebarOpen}
|
||||
onToggle={() => setSidebarOpen(!sidebarOpen)}
|
||||
onCommand={handleCommand}
|
||||
/>
|
||||
|
||||
{/* Main Terminal Area */}
|
||||
<div className={`flex-1 ${sidebarOpen ? 'ml-0' : ''} transition-all flex flex-col`}>
|
||||
{/* Tab Bar */}
|
||||
<TabBar
|
||||
tabs={tabBarTabs}
|
||||
onTabClick={(id) => tabs.setActiveWorkspace(id)}
|
||||
onTabClose={(id) => tabs.closeWorkspace(id)}
|
||||
onNewTab={handleCreateWorkspace}
|
||||
onTabRename={(id, name) => tabs.renameWorkspace(id, name)}
|
||||
/>
|
||||
|
||||
{/* Terminal */}
|
||||
<Terminal
|
||||
history={getActiveHistory()}
|
||||
isProcessing={isProcessing}
|
||||
outputRef={outputRef}
|
||||
onSubmit={handleCommand}
|
||||
getPreviousCommand={getPreviousCommand}
|
||||
getNextCommand={getNextCommand}
|
||||
resetCommandIndex={resetCommandIndex}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
1
MosaicIQ/src/assets/react.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
190
MosaicIQ/src/components/Home/Home.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
import React from 'react';
|
||||
|
||||
interface HomeProps {
|
||||
onStart: () => void;
|
||||
}
|
||||
|
||||
export const Home: React.FC<HomeProps> = ({ onStart }) => {
|
||||
const quickCommands = [
|
||||
{ cmd: '/search AAPL', desc: 'Search Apple Inc.', icon: '🔍' },
|
||||
{ cmd: '/portfolio', desc: 'View portfolio', icon: '📊' },
|
||||
{ cmd: '/news AAPL', desc: 'Latest news', icon: '📰' },
|
||||
{ cmd: '/analyze AAPL', desc: 'Run analysis', icon: '⚡' },
|
||||
];
|
||||
|
||||
const stats = [
|
||||
{ label: 'Companies', value: '12', color: 'text-[#58a6ff]' },
|
||||
{ label: 'Positions', value: '8', color: 'text-[#00d26a]' },
|
||||
{ label: 'Watchlist', value: '24', color: 'text-[#ffb000]' },
|
||||
{ label: 'Gain', value: '+12.4%', color: 'text-[#00d26a]' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="h-screen bg-[#0a0a0a] flex flex-col">
|
||||
{/* Header */}
|
||||
<header className="border-b border-[#2a2a2a] px-6 py-4">
|
||||
<div className="flex items-center justify-between max-w-6xl mx-auto">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-[#58a6ff] to-[#00d26a] rounded-lg flex items-center justify-center text-[#0a0a0a] font-bold text-lg">
|
||||
M
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg font-mono font-bold text-[#e0e0e0]">MosaicIQ</h1>
|
||||
<p className="text-[10px] text-[#888888] font-mono">Financial Research Terminal</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2 text-[10px] font-mono text-[#888888]">
|
||||
<span className="w-2 h-2 bg-[#00d26a] rounded-full animate-pulse"></span>
|
||||
Local Runtime Active
|
||||
</div>
|
||||
<button
|
||||
onClick={onStart}
|
||||
className="px-4 py-2 bg-[#1a1a1a] hover:bg-[#2a2a2a] border border-[#2a2a2a] hover:border-[#58a6ff] text-[#e0e0e0] text-xs font-mono rounded transition-all"
|
||||
>
|
||||
Enter Terminal
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="flex-1 overflow-auto p-6">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
{/* Hero Section */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-baseline gap-3 mb-2">
|
||||
<h2 className="text-2xl font-mono font-bold text-[#e0e0e0]">
|
||||
Welcome back
|
||||
</h2>
|
||||
<span className="text-sm font-mono text-[#888888]">
|
||||
v1.0.0
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-[#888888] font-mono max-w-2xl">
|
||||
Your local-first financial research workspace. Analyze companies, track portfolios,
|
||||
and stay informed with AI-powered insights.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-4 gap-4 mb-8">
|
||||
{stats.map((stat, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="bg-[#111111] border border-[#2a2a2a] p-4 rounded hover:border-[#3a3a3a] transition-all animate-in"
|
||||
style={{ animationDelay: `${idx * 75}ms` }}
|
||||
>
|
||||
<p className="text-[10px] text-[#888888] font-mono uppercase tracking-wide mb-1">
|
||||
{stat.label}
|
||||
</p>
|
||||
<p className={`text-xl font-mono font-bold ${stat.color}`}>
|
||||
{stat.value}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Quick Commands */}
|
||||
<div className="mb-8">
|
||||
<h3 className="text-sm font-mono font-semibold text-[#e0e0e0] mb-4 flex items-center gap-2">
|
||||
<span className="text-[#58a6ff]">$</span>
|
||||
Quick Commands
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{quickCommands.map((cmd, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
onClick={() => {
|
||||
// Simulate command execution
|
||||
onStart();
|
||||
}}
|
||||
className="group bg-[#111111] border border-[#2a2a2a] hover:border-[#58a6ff] p-4 rounded transition-all text-left animate-in"
|
||||
style={{ animationDelay: `${(idx + 4) * 75}ms` }}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<span className="text-lg">{cmd.icon}</span>
|
||||
<span className="text-[10px] font-mono text-[#666666] group-hover:text-[#58a6ff]">
|
||||
{cmd.cmd}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-[#e0e0e0] font-mono">{cmd.desc}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info Cards */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="bg-[#111111] border border-[#2a2a2a] p-5 rounded animate-in delay-200">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="text-[#00d26a]">●</span>
|
||||
<h4 className="text-sm font-mono font-semibold text-[#e0e0e0]">Local-First</h4>
|
||||
</div>
|
||||
<p className="text-xs text-[#888888] font-mono leading-relaxed">
|
||||
All data stored locally. Works offline. Fast, private, and always available.
|
||||
No cloud dependencies or API limits.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-[#111111] border border-[#2a2a2a] p-5 rounded animate-in delay-200">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="text-[#58a6ff]">●</span>
|
||||
<h4 className="text-sm font-mono font-semibold text-[#e0e0e0]">Terminal Interface</h4>
|
||||
</div>
|
||||
<p className="text-xs text-[#888888] font-mono leading-relaxed">
|
||||
Command-line interface for power users. Fast navigation, keyboard shortcuts,
|
||||
and AI-powered command completion.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Keyboard Shortcuts */}
|
||||
<div className="mt-8 p-4 bg-[#0a0a0a] border border-[#2a2a2a] rounded">
|
||||
<h4 className="text-xs font-mono text-[#888888] uppercase tracking-wide mb-3">
|
||||
Keyboard Shortcuts
|
||||
</h4>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<kbd className="px-2 py-1 bg-[#1a1a1a] border border-[#2a2a2a] rounded text-[10px] font-mono text-[#e0e0e0]">
|
||||
⌘K
|
||||
</kbd>
|
||||
<span className="text-xs text-[#888888] font-mono">Command palette</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<kbd className="px-2 py-1 bg-[#1a1a1a] border border-[#2a2a2a] rounded text-[10px] font-mono text-[#e0e0e0]">
|
||||
⌘B
|
||||
</kbd>
|
||||
<span className="text-xs text-[#888888] font-mono">Toggle sidebar</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<kbd className="px-2 py-1 bg-[#1a1a1a] border border-[#2a2a2a] rounded text-[10px] font-mono text-[#e0e0e0]">
|
||||
⌘L
|
||||
</kbd>
|
||||
<span className="text-xs text-[#888888] font-mono">Clear terminal</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<kbd className="px-2 py-1 bg-[#1a1a1a] border border-[#2a2a2a] rounded text-[10px] font-mono text-[#e0e0e0]">
|
||||
↑↓
|
||||
</kbd>
|
||||
<span className="text-xs text-[#888888] font-mono">Navigate history</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="border-t border-[#2a2a2a] px-6 py-3">
|
||||
<div className="max-w-6xl mx-auto flex items-center justify-between">
|
||||
<p className="text-[10px] text-[#666666] font-mono">
|
||||
MosaicIQ v1.0.0 · Built with React + Tauri · Local Runtime
|
||||
</p>
|
||||
<p className="text-[10px] text-[#666666] font-mono">
|
||||
Press <kbd className="px-1.5 py-0.5 bg-[#1a1a1a] border border-[#2a2a2a] rounded">Enter</kbd> to start
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
117
MosaicIQ/src/components/Panels/AnalysisPanel.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import React from 'react';
|
||||
import { StockAnalysis } from '../../types/financial';
|
||||
|
||||
interface AnalysisPanelProps {
|
||||
analysis: StockAnalysis;
|
||||
}
|
||||
|
||||
export const AnalysisPanel: React.FC<AnalysisPanelProps> = ({ analysis }) => {
|
||||
const getSentimentColor = (sentiment: string) => {
|
||||
switch (sentiment) {
|
||||
case 'bullish':
|
||||
return 'text-[#00d26a] bg-[#00d26a]/10 border-[#00d26a]/20';
|
||||
case 'bearish':
|
||||
return 'text-[#ff4757] bg-[#ff4757]/10 border-[#ff4757]/20';
|
||||
default:
|
||||
return 'text-[#888888] bg-[#888888]/10 border-[#888888]/20';
|
||||
}
|
||||
};
|
||||
|
||||
const getRecommendationColor = (rec: string) => {
|
||||
switch (rec) {
|
||||
case 'buy':
|
||||
return 'text-[#00d26a]';
|
||||
case 'sell':
|
||||
return 'text-[#ff4757]';
|
||||
default:
|
||||
return 'text-[#888888]';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-[#111111] border border-[#2a2a2a] rounded-lg overflow-hidden my-4">
|
||||
{/* Header */}
|
||||
<div className="bg-[#1a1a1a] px-4 py-3 border-b border-[#2a2a2a]">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<h3 className="text-lg font-mono font-bold text-[#e0e0e0]">{analysis.symbol}</h3>
|
||||
<span className={`text-[10px] font-mono uppercase tracking-wider px-2 py-1 rounded border ${getSentimentColor(analysis.sentiment)}`}>
|
||||
{analysis.sentiment}
|
||||
</span>
|
||||
</div>
|
||||
{analysis.targetPrice && (
|
||||
<div className="text-right">
|
||||
<div className="text-[10px] text-[#888888] font-mono uppercase tracking-wider">Target</div>
|
||||
<div className="text-xl font-mono font-bold text-[#e0e0e0]">${analysis.targetPrice.toFixed(0)}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Summary */}
|
||||
<div>
|
||||
<h4 className="text-[10px] text-[#888888] font-mono uppercase tracking-wider mb-2">Summary</h4>
|
||||
<p className="text-sm text-[#e0e0e0] leading-relaxed">{analysis.summary}</p>
|
||||
</div>
|
||||
|
||||
{/* Recommendation */}
|
||||
<div className="bg-[#1a1a1a] rounded-lg p-3">
|
||||
<div className="text-[10px] text-[#888888] font-mono uppercase tracking-wider mb-1">Recommendation</div>
|
||||
<div className={`text-2xl font-mono font-bold uppercase ${getRecommendationColor(analysis.recommendation)}`}>
|
||||
{analysis.recommendation}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Key Points */}
|
||||
{analysis.keyPoints.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-[10px] text-[#888888] font-mono uppercase tracking-wider mb-2">Key Points</h4>
|
||||
<ul className="space-y-1.5">
|
||||
{analysis.keyPoints.map((point, i) => (
|
||||
<li key={i} className="text-sm text-[#e0e0e0] flex items-start gap-2">
|
||||
<span className="text-[#58a6ff] mt-0.5">•</span>
|
||||
<span>{point}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Two Column: Risks & Opportunities */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* Risks */}
|
||||
{analysis.risks.length > 0 && (
|
||||
<div className="bg-[#1a1a1a] rounded p-3">
|
||||
<h4 className="text-[10px] text-[#ff4757] font-mono uppercase tracking-wider mb-2">Risks</h4>
|
||||
<ul className="space-y-1">
|
||||
{analysis.risks.map((risk, i) => (
|
||||
<li key={i} className="text-xs text-[#e0e0e0] flex items-start gap-1.5">
|
||||
<span className="text-[#ff4757]">⚠</span>
|
||||
<span>{risk}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Opportunities */}
|
||||
{analysis.opportunities.length > 0 && (
|
||||
<div className="bg-[#1a1a1a] rounded p-3">
|
||||
<h4 className="text-[10px] text-[#00d26a] font-mono uppercase tracking-wider mb-2">Opportunities</h4>
|
||||
<ul className="space-y-1">
|
||||
{analysis.opportunities.map((opp, i) => (
|
||||
<li key={i} className="text-xs text-[#e0e0e0] flex items-start gap-1.5">
|
||||
<span className="text-[#00d26a]">✓</span>
|
||||
<span>{opp}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
110
MosaicIQ/src/components/Panels/CompanyPanel.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import React from 'react';
|
||||
import { Company } from '../../types/financial';
|
||||
|
||||
interface CompanyPanelProps {
|
||||
company: Company;
|
||||
}
|
||||
|
||||
export const CompanyPanel: React.FC<CompanyPanelProps> = ({ company }) => {
|
||||
const formatCurrency = (value: number) => {
|
||||
if (value >= 1e12) return `$${(value / 1e12).toFixed(2)}T`;
|
||||
if (value >= 1e9) return `$${(value / 1e9).toFixed(2)}B`;
|
||||
if (value >= 1e6) return `$${(value / 1e6).toFixed(2)}M`;
|
||||
return `$${value.toFixed(2)}`;
|
||||
};
|
||||
|
||||
const formatNumber = (value: number) => {
|
||||
return new Intl.NumberFormat('en-US').format(value);
|
||||
};
|
||||
|
||||
const isPositive = company.change >= 0;
|
||||
|
||||
return (
|
||||
<div className="bg-[#111111] border border-[#2a2a2a] rounded-lg overflow-hidden my-4">
|
||||
{/* Header */}
|
||||
<div className="bg-[#1a1a1a] px-4 py-3 border-b border-[#2a2a2a]">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-xl font-mono font-bold text-[#e0e0e0]">{company.symbol}</h3>
|
||||
<p className="text-sm text-[#888888] font-mono mt-0.5">{company.name}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-2xl font-mono font-bold text-[#e0e0e0]">
|
||||
${company.price.toFixed(2)}
|
||||
</div>
|
||||
<div className={`text-sm font-mono mt-0.5 ${isPositive ? 'text-[#00d26a]' : 'text-[#ff4757]'}`}>
|
||||
{isPositive ? '+' : ''}{company.change.toFixed(2)} ({isPositive ? '+' : ''}{company.changePercent.toFixed(2)}%)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="p-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* Market Cap */}
|
||||
<div className="bg-[#1a1a1a] rounded p-3">
|
||||
<div className="text-[10px] text-[#888888] font-mono uppercase tracking-wider mb-1">Market Cap</div>
|
||||
<div className="text-lg font-mono text-[#e0e0e0]">{formatCurrency(company.marketCap)}</div>
|
||||
</div>
|
||||
|
||||
{/* Volume */}
|
||||
<div className="bg-[#1a1a1a] rounded p-3">
|
||||
<div className="text-[10px] text-[#888888] font-mono uppercase tracking-wider mb-1">Volume</div>
|
||||
<div className="text-lg font-mono text-[#e0e0e0]">{formatNumber(company.volume)}</div>
|
||||
</div>
|
||||
|
||||
{/* P/E Ratio */}
|
||||
{company.pe && (
|
||||
<div className="bg-[#1a1a1a] rounded p-3">
|
||||
<div className="text-[10px] text-[#888888] font-mono uppercase tracking-wider mb-1">P/E Ratio</div>
|
||||
<div className="text-lg font-mono text-[#e0e0e0]">{company.pe.toFixed(1)}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* EPS */}
|
||||
{company.eps && (
|
||||
<div className="bg-[#1a1a1a] rounded p-3">
|
||||
<div className="text-[10px] text-[#888888] font-mono uppercase tracking-wider mb-1">EPS</div>
|
||||
<div className="text-lg font-mono text-[#e0e0e0]">${company.eps.toFixed(2)}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 52W High */}
|
||||
{company.high52Week && (
|
||||
<div className="bg-[#1a1a1a] rounded p-3">
|
||||
<div className="text-[10px] text-[#888888] font-mono uppercase tracking-wider mb-1">52W High</div>
|
||||
<div className="text-lg font-mono text-[#00d26a]">${company.high52Week.toFixed(2)}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 52W Low */}
|
||||
{company.low52Week && (
|
||||
<div className="bg-[#1a1a1a] rounded p-3">
|
||||
<div className="text-[10px] text-[#888888] font-mono uppercase tracking-wider mb-1">52W Low</div>
|
||||
<div className="text-lg font-mono text-[#ff4757]">${company.low52Week.toFixed(2)}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mini Sparkline Placeholder */}
|
||||
<div className="mt-4 bg-[#1a1a1a] rounded p-3">
|
||||
<div className="text-[10px] text-[#888888] font-mono uppercase tracking-wider mb-2">Intraday</div>
|
||||
<div className="h-16 flex items-end gap-0.5">
|
||||
{Array.from({ length: 40 }).map((_, i) => {
|
||||
const height = 30 + Math.random() * 70;
|
||||
const isLast = i === 39;
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={`flex-1 rounded-sm ${isLast ? 'bg-[#58a6ff]' : 'bg-[#2a2a2a]'}`}
|
||||
style={{ height: `${height}%` }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
88
MosaicIQ/src/components/Panels/NewsPanel.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import React from 'react';
|
||||
import { NewsItem } from '../../types/financial';
|
||||
|
||||
interface NewsPanelProps {
|
||||
news: NewsItem[];
|
||||
ticker?: string;
|
||||
}
|
||||
|
||||
export const NewsPanel: React.FC<NewsPanelProps> = ({ news, ticker }) => {
|
||||
const formatTime = (date: Date) => {
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - date.getTime();
|
||||
const hours = Math.floor(diff / (1000 * 60 * 60));
|
||||
|
||||
if (hours < 1) {
|
||||
const minutes = Math.floor(diff / (1000 * 60));
|
||||
return `${minutes}m ago`;
|
||||
}
|
||||
if (hours < 24) {
|
||||
return `${hours}h ago`;
|
||||
}
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d ago`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-[#111111] border border-[#2a2a2a] rounded-lg overflow-hidden my-4">
|
||||
{/* Header */}
|
||||
<div className="bg-[#1a1a1a] px-4 py-3 border-b border-[#2a2a2a]">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-mono font-bold text-[#e0e0e0]">
|
||||
{ticker ? `News: ${ticker.toUpperCase()}` : 'Market News'}
|
||||
</h3>
|
||||
<span className="text-xs text-[#888888] font-mono">{news.length} articles</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* News List */}
|
||||
<div className="divide-y divide-[#2a2a2a]">
|
||||
{news.map((item) => (
|
||||
<article key={item.id} className="p-4 hover:bg-[#1a1a1a] transition-colors cursor-pointer">
|
||||
{/* Source & Time */}
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-[10px] font-mono uppercase tracking-wider text-[#58a6ff] bg-[#58a6ff]/10 px-2 py-0.5 rounded">
|
||||
{item.source}
|
||||
</span>
|
||||
<span className="text-[10px] text-[#888888] font-mono">
|
||||
{formatTime(item.timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Headline */}
|
||||
<h4 className="text-sm font-semibold text-[#e0e0e0] mb-2 leading-snug">
|
||||
{item.headline}
|
||||
</h4>
|
||||
|
||||
{/* Snippet */}
|
||||
<p className="text-xs text-[#888888] mb-3 leading-relaxed line-clamp-2">
|
||||
{item.snippet}
|
||||
</p>
|
||||
|
||||
{/* Related Tickers */}
|
||||
{item.relatedTickers.length > 0 && (
|
||||
<div className="flex gap-1.5 flex-wrap">
|
||||
{item.relatedTickers.map((ticker) => (
|
||||
<span
|
||||
key={ticker}
|
||||
className="text-[10px] font-mono text-[#888888] bg-[#2a2a2a] px-2 py-0.5 rounded hover:text-[#e0e0e0] hover:bg-[#3a3a3a] transition-colors cursor-pointer"
|
||||
>
|
||||
{ticker}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Empty State */}
|
||||
{news.length === 0 && (
|
||||
<div className="p-8 text-center">
|
||||
<div className="text-3xl mb-2">📰</div>
|
||||
<p className="text-[#888888] font-mono text-sm">No news articles found</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
95
MosaicIQ/src/components/Panels/PortfolioPanel.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import React from 'react';
|
||||
import { Portfolio } from '../../types/financial';
|
||||
|
||||
interface PortfolioPanelProps {
|
||||
portfolio: Portfolio;
|
||||
}
|
||||
|
||||
export const PortfolioPanel: React.FC<PortfolioPanelProps> = ({ portfolio }) => {
|
||||
const formatCurrency = (value: number) => {
|
||||
return `$${value.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
};
|
||||
|
||||
const totalGainPositive = portfolio.totalGain >= 0;
|
||||
const dayChangePositive = portfolio.dayChange >= 0;
|
||||
|
||||
return (
|
||||
<div className="bg-[#111111] border border-[#2a2a2a] rounded-lg overflow-hidden my-4">
|
||||
{/* Header */}
|
||||
<div className="bg-[#1a1a1a] px-4 py-3 border-b border-[#2a2a2a]">
|
||||
<h3 className="text-lg font-mono font-bold text-[#e0e0e0]">Portfolio Summary</h3>
|
||||
</div>
|
||||
|
||||
{/* Summary Stats */}
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Total Value */}
|
||||
<div className="bg-[#1a1a1a] rounded-lg p-4">
|
||||
<div className="text-[10px] text-[#888888] font-mono uppercase tracking-wider mb-1">Total Value</div>
|
||||
<div className="text-3xl font-mono font-bold text-[#e0e0e0]">
|
||||
{formatCurrency(portfolio.totalValue)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Day Change */}
|
||||
<div className="bg-[#1a1a1a] rounded-lg p-4">
|
||||
<div className="text-[10px] text-[#888888] font-mono uppercase tracking-wider mb-1">Today's Change</div>
|
||||
<div className={`text-2xl font-mono font-bold ${dayChangePositive ? 'text-[#00d26a]' : 'text-[#ff4757]'}`}>
|
||||
{dayChangePositive ? '+' : ''}{formatCurrency(portfolio.dayChange)} ({dayChangePositive ? '+' : ''}{portfolio.dayChangePercent.toFixed(2)}%)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Total Gain */}
|
||||
<div className="bg-[#1a1a1a] rounded-lg p-4">
|
||||
<div className="text-[10px] text-[#888888] font-mono uppercase tracking-wider mb-1">Total Gain/Loss</div>
|
||||
<div className={`text-2xl font-mono font-bold ${totalGainPositive ? 'text-[#00d26a]' : 'text-[#ff4757]'}`}>
|
||||
{totalGainPositive ? '+' : ''}{formatCurrency(portfolio.totalGain)} ({totalGainPositive ? '+' : ''}{portfolio.totalGainPercent.toFixed(2)}%)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Holdings Table */}
|
||||
<div className="border-t border-[#2a2a2a]">
|
||||
<div className="px-4 py-3 bg-[#0a0a0a]">
|
||||
<h4 className="text-sm font-mono font-semibold text-[#e0e0e0]">Holdings ({portfolio.holdings.length})</h4>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm font-mono">
|
||||
<thead className="bg-[#1a1a1a] text-[10px] text-[#888888] uppercase tracking-wider">
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left">Symbol</th>
|
||||
<th className="px-4 py-2 text-right">Qty</th>
|
||||
<th className="px-4 py-2 text-right">Avg Cost</th>
|
||||
<th className="px-4 py-2 text-right">Current</th>
|
||||
<th className="px-4 py-2 text-right">Value</th>
|
||||
<th className="px-4 py-2 text-right">Gain/Loss</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[#2a2a2a]">
|
||||
{portfolio.holdings.map((holding) => {
|
||||
const gainPositive = holding.gainLoss >= 0;
|
||||
return (
|
||||
<tr key={holding.symbol} className="hover:bg-[#1a1a1a]">
|
||||
<td className="px-4 py-3">
|
||||
<div className="font-semibold text-[#e0e0e0]">{holding.symbol}</div>
|
||||
<div className="text-[10px] text-[#888888]">{holding.name}</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-[#e0e0e0]">{holding.quantity}</td>
|
||||
<td className="px-4 py-3 text-right text-[#e0e0e0]">{formatCurrency(holding.avgCost)}</td>
|
||||
<td className="px-4 py-3 text-right text-[#e0e0e0]">{formatCurrency(holding.currentPrice)}</td>
|
||||
<td className="px-4 py-3 text-right text-[#e0e0e0]">{formatCurrency(holding.currentValue)}</td>
|
||||
<td className={`px-4 py-3 text-right font-semibold ${gainPositive ? 'text-[#00d26a]' : 'text-[#ff4757]'}`}>
|
||||
{gainPositive ? '+' : ''}{formatCurrency(holding.gainLoss)}
|
||||
<div className="text-[10px]">
|
||||
({gainPositive ? '+' : ''}{holding.gainLossPercent.toFixed(2)}%)
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
45
MosaicIQ/src/components/Sidebar/CompanyList.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import React from 'react';
|
||||
import { Company } from '../../types/financial';
|
||||
|
||||
interface CompanyListProps {
|
||||
companies: Company[];
|
||||
onCompanyClick: (symbol: string) => void;
|
||||
}
|
||||
|
||||
export const CompanyList: React.FC<CompanyListProps> = ({ companies, onCompanyClick }) => {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-[10px] font-mono uppercase tracking-wider text-[#888888] px-1">
|
||||
Latest Companies
|
||||
</h4>
|
||||
|
||||
<div className="space-y-1">
|
||||
{companies.map((company) => {
|
||||
const isPositive = company.change >= 0;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={company.symbol}
|
||||
onClick={() => onCompanyClick(company.symbol)}
|
||||
className="w-full bg-[#111111] hover:bg-[#1a1a1a] border border-[#2a2a2a] hover:border-[#3a3a3a] rounded p-2.5 text-left transition-all group"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="font-mono font-bold text-sm text-[#e0e0e0] group-hover:text-[#58a6ff] transition-colors">
|
||||
{company.symbol}
|
||||
</span>
|
||||
<span className={`text-xs font-mono ${isPositive ? 'text-[#00d26a]' : 'text-[#ff4757]'}`}>
|
||||
{isPositive ? '+' : ''}{company.changePercent.toFixed(2)}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] text-[#888888] truncate flex-1 mr-2">{company.name}</span>
|
||||
<span className="text-xs font-mono text-[#e0e0e0]">${company.price.toFixed(2)}</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
52
MosaicIQ/src/components/Sidebar/PortfolioSummary.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import React from 'react';
|
||||
import { Portfolio } from '../../types/financial';
|
||||
|
||||
interface PortfolioSummaryProps {
|
||||
portfolio: Portfolio;
|
||||
}
|
||||
|
||||
export const PortfolioSummary: React.FC<PortfolioSummaryProps> = ({ portfolio }) => {
|
||||
const formatCurrency = (value: number) => {
|
||||
if (value >= 1000) {
|
||||
return `$${(value / 1000).toFixed(1)}K`;
|
||||
}
|
||||
return `$${value.toFixed(0)}`;
|
||||
};
|
||||
|
||||
const isPositive = portfolio.dayChange >= 0;
|
||||
|
||||
return (
|
||||
<div className="bg-[#111111] rounded-lg p-3 border border-[#2a2a2a]">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h4 className="text-[10px] font-mono uppercase tracking-wider text-[#888888]">Portfolio</h4>
|
||||
<span className="text-[10px] font-mono text-[#888888]">{portfolio.holdings.length} positions</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<div className="text-[10px] text-[#888888] font-mono">Total Value</div>
|
||||
<div className="text-lg font-mono font-bold text-[#e0e0e0]">
|
||||
{formatCurrency(portfolio.totalValue)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`text-sm font-mono ${isPositive ? 'text-[#00d26a]' : 'text-[#ff4757]'}`}>
|
||||
{isPositive ? '+' : ''}{formatCurrency(portfolio.dayChange)} ({isPositive ? '+' : ''}{portfolio.dayChangePercent.toFixed(2)}%)
|
||||
<div className="text-[10px] text-[#888888]">Today</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mini Holdings List */}
|
||||
<div className="mt-3 pt-3 border-t border-[#2a2a2a] space-y-1.5">
|
||||
{portfolio.holdings.slice(0, 3).map((holding) => (
|
||||
<div key={holding.symbol} className="flex items-center justify-between text-xs">
|
||||
<span className="font-mono text-[#e0e0e0]">{holding.symbol}</span>
|
||||
<span className={`font-mono ${holding.gainLoss >= 0 ? 'text-[#00d26a]' : 'text-[#ff4757]'}`}>
|
||||
{holding.gainLoss >= 0 ? '+' : ''}{holding.gainLossPercent.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
142
MosaicIQ/src/components/Sidebar/Sidebar.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import React from 'react';
|
||||
import { CompanyList } from './CompanyList';
|
||||
import { PortfolioSummary } from './PortfolioSummary';
|
||||
import { useMockData } from '../../hooks/useMockData';
|
||||
|
||||
interface SidebarProps {
|
||||
onCommand: (command: string) => void;
|
||||
isOpen: boolean;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
type SidebarState = 'closed' | 'minimized' | 'open';
|
||||
|
||||
export const Sidebar: React.FC<SidebarProps> = ({ onCommand, isOpen, onToggle }) => {
|
||||
const { getAllCompanies, getPortfolio } = useMockData();
|
||||
const companies = getAllCompanies();
|
||||
const portfolio = getPortfolio();
|
||||
|
||||
const handleCompanyClick = (symbol: string) => {
|
||||
onCommand(`/search ${symbol}`);
|
||||
};
|
||||
|
||||
// Cycle through states: closed -> minimized -> open -> closed
|
||||
const cycleState = (): SidebarState => {
|
||||
if (!isOpen) return 'minimized';
|
||||
return 'open';
|
||||
};
|
||||
|
||||
const state = cycleState();
|
||||
|
||||
// Closed state - small collapsed sidebar with just toggle button
|
||||
if (state === 'closed') {
|
||||
return (
|
||||
<div className="w-8 bg-[#0a0a0a] border-r border-[#2a2a2a] flex flex-col items-center py-4">
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="text-[#888888] hover:text-[#e0e0e0] p-1 rounded hover:bg-[#1a1a1a] transition-colors"
|
||||
title="Show sidebar (Cmd+B)"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Minimized state - show icons only
|
||||
if (state === 'minimized') {
|
||||
return (
|
||||
<div className="w-16 bg-[#0a0a0a] border-r border-[#2a2a2a] flex flex-col">
|
||||
{/* Header with toggle button */}
|
||||
<div className="p-3 border-b border-[#2a2a2a] flex justify-center">
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="text-[#888888] hover:text-[#e0e0e0] p-1 rounded hover:bg-[#1a1a1a] transition-colors"
|
||||
title="Expand sidebar (Cmd+B)"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 5l7 7-7 7M5 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content - Just ticker icons */}
|
||||
<div className="flex-1 overflow-y-auto p-2 space-y-2">
|
||||
{/* Portfolio icon */}
|
||||
<button
|
||||
onClick={() => onCommand('/portfolio')}
|
||||
className="w-full aspect-square bg-[#1a1a1a] hover:bg-[#2a2a2a] rounded flex items-center justify-center transition-colors group relative"
|
||||
title="Portfolio"
|
||||
>
|
||||
<span className="text-2xl">💼</span>
|
||||
<span className="absolute left-full ml-2 px-2 py-1 bg-[#1a1a1a] border border-[#2a2a2a] rounded text-xs text-[#e0e0e0] opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-20 pointer-events-none">
|
||||
Portfolio
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Company tickers */}
|
||||
{companies.slice(0, 7).map((company) => {
|
||||
const isPositive = company.change >= 0;
|
||||
return (
|
||||
<button
|
||||
key={company.symbol}
|
||||
onClick={() => handleCompanyClick(company.symbol)}
|
||||
className="w-full aspect-square bg-[#1a1a1a] hover:bg-[#2a2a2a] rounded flex items-center justify-center transition-colors group relative"
|
||||
title={company.name}
|
||||
>
|
||||
<span className={`text-xs font-mono font-bold ${isPositive ? 'text-[#00d26a]' : 'text-[#ff4757]'}`}>
|
||||
{company.symbol.slice(0, 2)}
|
||||
</span>
|
||||
<span className="absolute left-full ml-2 px-2 py-1 bg-[#1a1a1a] border border-[#2a2a2a] rounded text-xs text-[#e0e0e0] opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-20 pointer-events-none">
|
||||
{company.symbol}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Open state - full sidebar
|
||||
return (
|
||||
<div className="w-70 bg-[#0a0a0a] border-r border-[#2a2a2a] flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b border-[#2a2a2a]">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-lg font-mono font-bold text-[#e0e0e0]">MosaicIQ</h1>
|
||||
<p className="text-[10px] text-[#888888] font-mono">Financial Terminal v1.0</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="text-[#888888] hover:text-[#e0e0e0] p-1 rounded hover:bg-[#1a1a1a] transition-colors"
|
||||
title="Minimize sidebar (Cmd+B)"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 19l-7-7 7-7m8 14l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||
{/* Portfolio Summary */}
|
||||
<PortfolioSummary portfolio={portfolio} />
|
||||
|
||||
{/* Company List */}
|
||||
<CompanyList companies={companies.slice(0, 7)} onCompanyClick={handleCompanyClick} />
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-4 border-t border-[#2a2a2a]">
|
||||
<div className="text-[10px] text-[#888888] font-mono text-center">
|
||||
Press <kbd className="px-1.5 py-0.5 bg-[#1a1a1a] rounded text-[#e0e0e0]">Cmd+B</kbd> to toggle
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
123
MosaicIQ/src/components/TabBar/TabBar.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
|
||||
export interface Tab {
|
||||
id: string;
|
||||
name: string;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
interface TabBarProps {
|
||||
tabs: Tab[];
|
||||
onTabClick: (id: string) => void;
|
||||
onTabClose: (id: string) => void;
|
||||
onNewTab: () => void;
|
||||
onTabRename?: (id: string, newName: string) => void;
|
||||
}
|
||||
|
||||
export const TabBar: React.FC<TabBarProps> = ({
|
||||
tabs,
|
||||
onTabClick,
|
||||
onTabClose,
|
||||
onNewTab,
|
||||
onTabRename
|
||||
}) => {
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editValue, setEditValue] = useState('');
|
||||
const editInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (editingId && editInputRef.current) {
|
||||
editInputRef.current.focus();
|
||||
editInputRef.current.select();
|
||||
}
|
||||
}, [editingId]);
|
||||
|
||||
const handleDoubleClick = (tab: Tab) => {
|
||||
setEditingId(tab.id);
|
||||
setEditValue(tab.name);
|
||||
};
|
||||
|
||||
const handleEditSubmit = () => {
|
||||
if (editingId && editValue.trim() && onTabRename) {
|
||||
onTabRename(editingId, editValue.trim());
|
||||
}
|
||||
setEditingId(null);
|
||||
setEditValue('');
|
||||
};
|
||||
|
||||
const handleEditKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleEditSubmit();
|
||||
} else if (e.key === 'Escape') {
|
||||
setEditingId(null);
|
||||
setEditValue('');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-10 bg-[#0a0a0a] border-b border-[#2a2a2a] flex items-center px-2 gap-1">
|
||||
{tabs.map((tab) => (
|
||||
<div
|
||||
key={tab.id}
|
||||
className={`group flex items-center gap-1.5 px-2 py-1 rounded-t tab-transition cursor-pointer border border-transparent ${
|
||||
tab.isActive
|
||||
? 'bg-[#111111] border-b-[#0a0a0a] -mb-px border-t border-l border-r border-[#2a2a2a]'
|
||||
: 'bg-[#0a0a0a] hover:bg-[#1a1a1a] border-t border-l border-r border-transparent'
|
||||
}`}
|
||||
onClick={() => !editingId && onTabClick(tab.id)}
|
||||
onDoubleClick={() => !editingId && handleDoubleClick(tab)}
|
||||
>
|
||||
{/* Terminal Icon */}
|
||||
<svg className={`w-3 h-3 flex-shrink-0 ${tab.isActive ? 'text-[#58a6ff]' : 'text-[#888888]'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
|
||||
{/* Tab Name or Edit Input */}
|
||||
{editingId === tab.id ? (
|
||||
<input
|
||||
ref={editInputRef}
|
||||
type="text"
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onBlur={handleEditSubmit}
|
||||
onKeyDown={handleEditKeyDown}
|
||||
className="bg-[#1a1a1a] border border-[#58a6ff] rounded px-1.5 py-0.5 text-xs font-mono text-[#e0e0e0] outline-none w-28"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
) : (
|
||||
<span className={`text-xs font-mono truncate max-w-[100px] ${tab.isActive ? 'text-[#e0e0e0]' : 'text-[#888888]'}`}>
|
||||
{tab.name}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Close Button */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onTabClose(tab.id);
|
||||
}}
|
||||
className={`opacity-0 group-hover:opacity-100 flex-shrink-0 p-0.5 rounded hover:bg-[#2a2a2a] transition-all ${
|
||||
tab.isActive ? 'opacity-100' : ''
|
||||
}`}
|
||||
title="Close tab"
|
||||
>
|
||||
<svg className="w-3 h-3 text-[#888888] hover:text-[#e0e0e0]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* New Tab Button */}
|
||||
<button
|
||||
onClick={onNewTab}
|
||||
className="flex items-center justify-center w-7 h-7 rounded hover:bg-[#1a1a1a] text-[#888888] hover:text-[#e0e0e0] transition-colors flex-shrink-0"
|
||||
title="New terminal (Cmd+T)"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
157
MosaicIQ/src/components/Terminal/CommandInput.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import React, { useState, useRef, useEffect, KeyboardEvent } from 'react';
|
||||
|
||||
interface CommandInputProps {
|
||||
onSubmit: (command: string) => void;
|
||||
isProcessing: boolean;
|
||||
getPreviousCommand: () => string | null;
|
||||
getNextCommand: () => string | null;
|
||||
resetCommandIndex: () => void;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export const CommandInput: React.FC<CommandInputProps> = ({
|
||||
onSubmit,
|
||||
isProcessing,
|
||||
getPreviousCommand,
|
||||
getNextCommand,
|
||||
resetCommandIndex,
|
||||
placeholder = 'Type command or natural language query...'
|
||||
}) => {
|
||||
const [input, setInput] = useState('');
|
||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const suggestionsRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const suggestions = [
|
||||
{ command: '/search', description: 'Search for stock' },
|
||||
{ command: '/portfolio', description: 'Show portfolio' },
|
||||
{ command: '/news', description: 'Market news' },
|
||||
{ command: '/analyze', description: 'AI analysis' },
|
||||
{ command: '/help', description: 'List commands' }
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
if (!isProcessing) {
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
}, [isProcessing]);
|
||||
|
||||
const handleSubmit = () => {
|
||||
const trimmed = input.trim();
|
||||
if (trimmed && !isProcessing) {
|
||||
onSubmit(trimmed);
|
||||
setInput('');
|
||||
setShowSuggestions(false);
|
||||
resetCommandIndex();
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
const prev = getPreviousCommand();
|
||||
if (prev !== null) {
|
||||
setInput(prev);
|
||||
}
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
const next = getNextCommand();
|
||||
if (next !== null) {
|
||||
setInput(next);
|
||||
}
|
||||
} else if (e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
// Simple autocomplete
|
||||
if (input.startsWith('/')) {
|
||||
const match = suggestions.find(s => s.command.startsWith(input));
|
||||
if (match) {
|
||||
setInput(match.command + ' ');
|
||||
}
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
setShowSuggestions(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setInput(e.target.value);
|
||||
if (e.target.value.startsWith('/')) {
|
||||
setShowSuggestions(true);
|
||||
} else {
|
||||
setShowSuggestions(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSuggestionClick = (command: string) => {
|
||||
setInput(command + ' ');
|
||||
setShowSuggestions(false);
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{/* Suggestions Dropdown */}
|
||||
{showSuggestions && suggestions.some(s => s.command.startsWith(input)) && (
|
||||
<div
|
||||
ref={suggestionsRef}
|
||||
className="absolute top-full left-0 right-0 mt-2 bg-[#1a1a1a] border border-[#2a2a2a] rounded-md overflow-hidden shadow-lg z-20"
|
||||
>
|
||||
{suggestions
|
||||
.filter(s => !input || s.command.startsWith(input))
|
||||
.map((suggestion, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
className="w-full text-left px-4 py-2 hover:bg-[#2a2a2a] transition-colors font-mono text-sm"
|
||||
onClick={() => handleSuggestionClick(suggestion.command)}
|
||||
>
|
||||
<span className="text-[#58a6ff]">{suggestion.command}</span>
|
||||
<span className="text-[#888888] ml-2">{suggestion.description}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input Bar */}
|
||||
<div className="flex items-center gap-2 bg-[#1a1a1a] border border-[#2a2a2a] rounded-md px-4 py-3 focus-within:border-[#58a6ff] focus-within:shadow-[0_0_0_2px_rgba(88,166,255,0.1)] transition-all">
|
||||
{/* Prompt */}
|
||||
<span className="text-[#58a6ff] font-mono text-lg select-none">{'>'}</span>
|
||||
|
||||
{/* Input */}
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
disabled={isProcessing}
|
||||
className="flex-1 bg-transparent border-none outline-none text-[#e0e0e0] font-mono text-[15px] placeholder-[#888888] disabled:opacity-50"
|
||||
/>
|
||||
|
||||
{/* Processing Indicator */}
|
||||
{isProcessing && (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="w-2 h-2 bg-[#58a6ff] rounded-full animate-pulse"></span>
|
||||
<span className="w-2 h-2 bg-[#58a6ff] rounded-full animate-pulse delay-75"></span>
|
||||
<span className="w-2 h-2 bg-[#58a6ff] rounded-full animate-pulse delay-150"></span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit Hint */}
|
||||
{!isProcessing && input && (
|
||||
<span className="text-[#888888] text-xs font-mono select-none">↵</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Command hint */}
|
||||
<div className="mt-2 flex gap-4 text-xs text-[#888888] font-mono">
|
||||
<span>↑/↓ history</span>
|
||||
<span>Tab autocomplete</span>
|
||||
<span>Ctrl+L clear</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
45
MosaicIQ/src/components/Terminal/Terminal.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import React from 'react';
|
||||
import { TerminalEntry } from '../../types/terminal';
|
||||
import { TerminalOutput } from './TerminalOutput';
|
||||
import { CommandInput } from './CommandInput';
|
||||
|
||||
interface TerminalProps {
|
||||
history: TerminalEntry[];
|
||||
isProcessing: boolean;
|
||||
outputRef: React.RefObject<HTMLDivElement | null>;
|
||||
onSubmit: (command: string) => void;
|
||||
getPreviousCommand: () => string | null;
|
||||
getNextCommand: () => string | null;
|
||||
resetCommandIndex: () => void;
|
||||
}
|
||||
|
||||
export const Terminal: React.FC<TerminalProps> = ({
|
||||
history,
|
||||
isProcessing,
|
||||
outputRef,
|
||||
onSubmit,
|
||||
getPreviousCommand,
|
||||
getNextCommand,
|
||||
resetCommandIndex
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-[#0a0a0a] relative">
|
||||
{/* Subtle scanline effect */}
|
||||
<div className="absolute inset-0 pointer-events-none opacity-[0.03] bg-[repeating-linear-gradient(0deg,transparent,transparent_2px,rgba(0,0,0,0.1)_2px,rgba(0,0,0,0.1)_4px)]" />
|
||||
|
||||
{/* Command Input */}
|
||||
<div className="flex-shrink-0 p-6 border-b border-[#2a2a2a] bg-[#0a0a0a]">
|
||||
<CommandInput
|
||||
onSubmit={onSubmit}
|
||||
isProcessing={isProcessing}
|
||||
getPreviousCommand={getPreviousCommand}
|
||||
getNextCommand={getNextCommand}
|
||||
resetCommandIndex={resetCommandIndex}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Terminal Output */}
|
||||
<TerminalOutput history={history} outputRef={outputRef} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
122
MosaicIQ/src/components/Terminal/TerminalOutput.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { PanelPayload, TerminalEntry } from '../../types/terminal';
|
||||
import { CompanyPanel } from '../Panels/CompanyPanel';
|
||||
import { PortfolioPanel } from '../Panels/PortfolioPanel';
|
||||
import { NewsPanel } from '../Panels/NewsPanel';
|
||||
import { AnalysisPanel } from '../Panels/AnalysisPanel';
|
||||
|
||||
interface TerminalOutputProps {
|
||||
history: TerminalEntry[];
|
||||
outputRef: React.RefObject<HTMLDivElement | null>;
|
||||
}
|
||||
|
||||
export const TerminalOutput: React.FC<TerminalOutputProps> = ({ history, outputRef }) => {
|
||||
// Auto-scroll to bottom when history changes
|
||||
useEffect(() => {
|
||||
if (outputRef.current) {
|
||||
outputRef.current.scrollTop = outputRef.current.scrollHeight;
|
||||
}
|
||||
}, [history, outputRef]);
|
||||
|
||||
const renderContent = (entry: TerminalEntry) => {
|
||||
if (typeof entry.content === 'string') {
|
||||
const lines = entry.content.split('\n');
|
||||
return (
|
||||
<div className="whitespace-pre-wrap font-mono text-[14px] leading-relaxed">
|
||||
{lines.map((line, i) => (
|
||||
<div key={i}>{line || '\u00A0'}</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const getEntryColor = (type: TerminalEntry['type']) => {
|
||||
switch (type) {
|
||||
case 'command':
|
||||
return 'text-[#e0e0e0]';
|
||||
case 'response':
|
||||
return 'text-[#58a6ff]';
|
||||
case 'system':
|
||||
return 'text-[#888888] italic';
|
||||
case 'error':
|
||||
return 'text-[#ff4757]';
|
||||
case 'panel':
|
||||
return '';
|
||||
default:
|
||||
return 'text-[#e0e0e0]';
|
||||
}
|
||||
};
|
||||
|
||||
const renderPanel = (entry: TerminalEntry) => {
|
||||
if (entry.type !== 'panel' || typeof entry.content === 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const panelData = entry.content as PanelPayload;
|
||||
|
||||
switch (panelData.type) {
|
||||
case 'company':
|
||||
return <CompanyPanel company={panelData.data} />;
|
||||
case 'portfolio':
|
||||
return <PortfolioPanel portfolio={panelData.data} />;
|
||||
case 'news':
|
||||
return <NewsPanel news={panelData.data} ticker={panelData.ticker} />;
|
||||
case 'analysis':
|
||||
return <AnalysisPanel analysis={panelData.data} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={outputRef}
|
||||
className="flex-1 overflow-y-auto px-6 py-4 space-y-4"
|
||||
style={{
|
||||
scrollbarWidth: 'thin',
|
||||
scrollbarColor: '#2a2a2a #111111'
|
||||
}}
|
||||
>
|
||||
{history.map((entry) => (
|
||||
<div key={entry.id} className="animate-in fade-in slide-in-from-bottom-2 duration-200">
|
||||
{/* Entry Header */}
|
||||
{entry.type === 'command' && (
|
||||
<div className="flex items-start gap-2 mb-1">
|
||||
<span className="text-[#58a6ff] font-mono select-none">{'>'}</span>
|
||||
<div className={getEntryColor(entry.type)}>
|
||||
{renderContent(entry)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{entry.type !== 'command' && entry.type !== 'panel' && (
|
||||
<div className={getEntryColor(entry.type)}>
|
||||
{renderContent(entry)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Render Panel */}
|
||||
{entry.type === 'panel' && renderPanel(entry)}
|
||||
|
||||
{/* Timestamp */}
|
||||
{entry.timestamp && (
|
||||
<div className="mt-1 text-[10px] text-[#666666] font-mono">
|
||||
{entry.timestamp.toLocaleTimeString('en-US', { hour12: false })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Empty State */}
|
||||
{history.length === 0 && (
|
||||
<div className="text-[#888888] font-mono text-center py-20">
|
||||
<div className="text-4xl mb-4">⚡</div>
|
||||
<div>Terminal ready. Type a command to get started.</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
17
MosaicIQ/src/hooks/useMockData.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import {
|
||||
getAllCompanies,
|
||||
getAnalysis,
|
||||
getCompany,
|
||||
getNews,
|
||||
getPortfolio,
|
||||
searchCompanies,
|
||||
} from '../lib/mockData';
|
||||
|
||||
export const useMockData = () => ({
|
||||
getCompany,
|
||||
getAllCompanies,
|
||||
getPortfolio,
|
||||
getNews,
|
||||
getAnalysis,
|
||||
searchCompanies,
|
||||
});
|
||||
156
MosaicIQ/src/hooks/useTabs.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { TerminalEntry } from '../types/terminal';
|
||||
|
||||
export interface Workspace {
|
||||
id: string;
|
||||
name: string;
|
||||
history: TerminalEntry[];
|
||||
chatSessionId?: string;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export const useTabs = () => {
|
||||
const [workspaces, setWorkspaces] = useState<Workspace[]>([
|
||||
{
|
||||
id: '1',
|
||||
name: 'Terminal 1',
|
||||
history: [
|
||||
{
|
||||
id: 'welcome',
|
||||
type: 'system',
|
||||
content: 'MosaicIQ Financial Terminal v1.0\nSlash commands (/) clear the panel.\nNatural language builds a conversation.',
|
||||
timestamp: new Date()
|
||||
}
|
||||
],
|
||||
chatSessionId: undefined,
|
||||
createdAt: new Date()
|
||||
}
|
||||
]);
|
||||
const [activeWorkspaceId, setActiveWorkspaceId] = useState('1');
|
||||
|
||||
const activeWorkspace = workspaces.find(w => w.id === activeWorkspaceId) || workspaces[0];
|
||||
|
||||
const createWorkspace = useCallback(() => {
|
||||
const newWorkspace: Workspace = {
|
||||
id: Date.now().toString(),
|
||||
name: `Terminal ${workspaces.length + 1}`,
|
||||
history: [
|
||||
{
|
||||
id: `welcome-${Date.now()}`,
|
||||
type: 'system',
|
||||
content: 'MosaicIQ Financial Terminal v1.0\nSlash commands (/) clear the panel.\nNatural language builds a conversation.',
|
||||
timestamp: new Date()
|
||||
}
|
||||
],
|
||||
chatSessionId: undefined,
|
||||
createdAt: new Date()
|
||||
};
|
||||
|
||||
setWorkspaces(prev => [...prev, newWorkspace]);
|
||||
setActiveWorkspaceId(newWorkspace.id);
|
||||
return newWorkspace.id;
|
||||
}, [workspaces.length]);
|
||||
|
||||
const closeWorkspace = useCallback((id: string) => {
|
||||
// Don't allow closing the last workspace
|
||||
if (workspaces.length === 1) return;
|
||||
|
||||
setWorkspaces(prev => {
|
||||
const filtered = prev.filter(w => w.id !== id);
|
||||
|
||||
// If we closed the active workspace, switch to another
|
||||
if (id === activeWorkspaceId) {
|
||||
const newActive = filtered[0];
|
||||
setActiveWorkspaceId(newActive.id);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
});
|
||||
}, [workspaces, activeWorkspaceId]);
|
||||
|
||||
const setActiveWorkspace = useCallback((id: string) => {
|
||||
setActiveWorkspaceId(id);
|
||||
}, []);
|
||||
|
||||
const updateWorkspaceHistory = useCallback((id: string, history: TerminalEntry[]) => {
|
||||
setWorkspaces(prev =>
|
||||
prev.map(w =>
|
||||
w.id === id ? { ...w, history } : w
|
||||
)
|
||||
);
|
||||
}, []);
|
||||
|
||||
const appendWorkspaceEntry = useCallback((id: string, entry: TerminalEntry) => {
|
||||
// Appending in place keeps a stable entry id available for later stream updates.
|
||||
setWorkspaces((prev) =>
|
||||
prev.map((workspace) =>
|
||||
workspace.id === id
|
||||
? { ...workspace, history: [...workspace.history, entry] }
|
||||
: workspace,
|
||||
),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const updateWorkspaceEntry = useCallback(
|
||||
(
|
||||
id: string,
|
||||
entryId: string,
|
||||
updater: (entry: TerminalEntry) => TerminalEntry,
|
||||
) => {
|
||||
// Update a single entry without rebuilding unrelated workspaces.
|
||||
setWorkspaces((prev) =>
|
||||
prev.map((workspace) =>
|
||||
workspace.id === id
|
||||
? {
|
||||
...workspace,
|
||||
history: workspace.history.map((entry) =>
|
||||
entry.id === entryId ? updater(entry) : entry,
|
||||
),
|
||||
}
|
||||
: workspace,
|
||||
),
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const clearWorkspace = useCallback((id: string) => {
|
||||
setWorkspaces((prev) =>
|
||||
prev.map((workspace) =>
|
||||
workspace.id === id ? { ...workspace, history: [] } : workspace,
|
||||
),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const setWorkspaceSession = useCallback((id: string, chatSessionId?: string) => {
|
||||
// Session ids are scoped per workspace so each tab can maintain an independent conversation.
|
||||
setWorkspaces((prev) =>
|
||||
prev.map((workspace) =>
|
||||
workspace.id === id ? { ...workspace, chatSessionId } : workspace,
|
||||
),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const renameWorkspace = useCallback((id: string, name: string) => {
|
||||
setWorkspaces(prev =>
|
||||
prev.map(w =>
|
||||
w.id === id ? { ...w, name } : w
|
||||
)
|
||||
);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
workspaces,
|
||||
activeWorkspace,
|
||||
activeWorkspaceId,
|
||||
createWorkspace,
|
||||
closeWorkspace,
|
||||
setActiveWorkspace,
|
||||
updateWorkspaceHistory,
|
||||
appendWorkspaceEntry,
|
||||
updateWorkspaceEntry,
|
||||
clearWorkspace,
|
||||
setWorkspaceSession,
|
||||
renameWorkspace
|
||||
};
|
||||
};
|
||||
108
MosaicIQ/src/hooks/useTerminal.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { TerminalEntry, TerminalState } from '../types/terminal';
|
||||
|
||||
// Helper to create an entry with timestamp (exported for use in App.tsx)
|
||||
export const createEntry = (
|
||||
entry: Omit<TerminalEntry, 'id' | 'timestamp'>
|
||||
): TerminalEntry => ({
|
||||
...entry,
|
||||
id: `${Date.now()}-${Math.random()}`,
|
||||
timestamp: new Date()
|
||||
});
|
||||
|
||||
export const useTerminal = () => {
|
||||
const [state, setState] = useState<TerminalState>({
|
||||
history: [
|
||||
{
|
||||
id: 'welcome',
|
||||
type: 'system',
|
||||
content: 'MosaicIQ Financial Terminal v1.0\nType /help for available commands or try a natural language query.',
|
||||
timestamp: new Date()
|
||||
}
|
||||
],
|
||||
currentIndex: -1,
|
||||
isProcessing: false
|
||||
});
|
||||
|
||||
const commandHistoryRef = useRef<string[]>([]);
|
||||
const outputRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Auto-scroll to bottom when new entries are added
|
||||
useEffect(() => {
|
||||
if (outputRef.current) {
|
||||
outputRef.current.scrollTop = outputRef.current.scrollHeight;
|
||||
}
|
||||
}, [state.history]);
|
||||
|
||||
const addEntry = useCallback((entry: Omit<TerminalEntry, 'id' | 'timestamp'>) => {
|
||||
const newEntry: TerminalEntry = createEntry(entry);
|
||||
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
history: [...prev.history, newEntry]
|
||||
}));
|
||||
|
||||
return newEntry.id;
|
||||
}, []);
|
||||
|
||||
const addCommand = useCallback((command: string) => {
|
||||
commandHistoryRef.current.push(command);
|
||||
const entry: TerminalEntry = createEntry({
|
||||
type: 'command',
|
||||
content: command
|
||||
});
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
history: [...prev.history, entry]
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const setProcessing = useCallback((isProcessing: boolean) => {
|
||||
setState(prev => ({ ...prev, isProcessing }));
|
||||
}, []);
|
||||
|
||||
const clearTerminal = useCallback(() => {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
history: []
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const getPreviousCommand = useCallback(() => {
|
||||
const history = commandHistoryRef.current;
|
||||
if (state.currentIndex < history.length - 1) {
|
||||
const newIndex = state.currentIndex + 1;
|
||||
setState(prev => ({ ...prev, currentIndex: newIndex }));
|
||||
return history[history.length - 1 - newIndex];
|
||||
}
|
||||
return null;
|
||||
}, [state.currentIndex]);
|
||||
|
||||
const getNextCommand = useCallback(() => {
|
||||
if (state.currentIndex > 0) {
|
||||
const newIndex = state.currentIndex - 1;
|
||||
setState(prev => ({ ...prev, currentIndex: newIndex }));
|
||||
return commandHistoryRef.current[commandHistoryRef.current.length - 1 - newIndex];
|
||||
} else if (state.currentIndex === 0) {
|
||||
setState(prev => ({ ...prev, currentIndex: -1 }));
|
||||
return '';
|
||||
}
|
||||
return null;
|
||||
}, [state.currentIndex]);
|
||||
|
||||
const resetCommandIndex = useCallback(() => {
|
||||
setState(prev => ({ ...prev, currentIndex: -1 }));
|
||||
}, []);
|
||||
|
||||
return {
|
||||
state,
|
||||
outputRef,
|
||||
addEntry,
|
||||
addCommand,
|
||||
setProcessing,
|
||||
clearTerminal,
|
||||
getPreviousCommand,
|
||||
getNextCommand,
|
||||
resetCommandIndex
|
||||
};
|
||||
};
|
||||
50
MosaicIQ/src/lib/mockData.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import rawMockFinancialData from '../shared/mock-financial-data.json';
|
||||
import {
|
||||
Company,
|
||||
MockFinancialData,
|
||||
NewsItem,
|
||||
SerializedNewsItem,
|
||||
StockAnalysis,
|
||||
Portfolio,
|
||||
} from '../types/financial';
|
||||
|
||||
const mockFinancialData = rawMockFinancialData as MockFinancialData;
|
||||
|
||||
const toNewsItem = (item: SerializedNewsItem): NewsItem => ({
|
||||
...item,
|
||||
timestamp: new Date(item.timestamp),
|
||||
});
|
||||
|
||||
export const getCompany = (symbol: string): Company | undefined =>
|
||||
mockFinancialData.companies.find(
|
||||
(company) => company.symbol.toUpperCase() === symbol.toUpperCase(),
|
||||
);
|
||||
|
||||
export const getAllCompanies = (): Company[] => mockFinancialData.companies;
|
||||
|
||||
export const getPortfolio = (): Portfolio => mockFinancialData.portfolio;
|
||||
|
||||
export const getNews = (symbol?: string): NewsItem[] => {
|
||||
const items = symbol
|
||||
? mockFinancialData.newsItems.filter((newsItem) =>
|
||||
newsItem.relatedTickers.some(
|
||||
(ticker) => ticker.toUpperCase() === symbol.toUpperCase(),
|
||||
),
|
||||
)
|
||||
: mockFinancialData.newsItems;
|
||||
|
||||
return items.map(toNewsItem);
|
||||
};
|
||||
|
||||
export const getAnalysis = (symbol: string): StockAnalysis | undefined =>
|
||||
mockFinancialData.analyses[symbol.toUpperCase()];
|
||||
|
||||
export const searchCompanies = (query: string): Company[] => {
|
||||
const normalizedQuery = query.toLowerCase();
|
||||
|
||||
return mockFinancialData.companies.filter(
|
||||
(company) =>
|
||||
company.symbol.toLowerCase().includes(normalizedQuery) ||
|
||||
company.name.toLowerCase().includes(normalizedQuery),
|
||||
);
|
||||
};
|
||||
129
MosaicIQ/src/lib/terminalBridge.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
|
||||
import { NewsItem } from '../types/financial';
|
||||
import {
|
||||
AgentDeltaEvent,
|
||||
AgentErrorEvent,
|
||||
AgentResultEvent,
|
||||
ChatStreamStart,
|
||||
ExecuteTerminalCommandRequest,
|
||||
PanelPayload,
|
||||
ResolvedTerminalCommandResponse,
|
||||
StartChatStreamRequest,
|
||||
TerminalCommandResponse,
|
||||
TransportPanelPayload,
|
||||
} from '../types/terminal';
|
||||
|
||||
interface StreamCallbacks {
|
||||
workspaceId: string;
|
||||
onDelta: (event: AgentDeltaEvent) => void;
|
||||
onResult: (event: AgentResultEvent) => void;
|
||||
onError: (event: AgentErrorEvent) => void;
|
||||
}
|
||||
|
||||
const deserializePanelPayload = (payload: TransportPanelPayload): PanelPayload => {
|
||||
if (payload.type !== 'news') {
|
||||
return payload;
|
||||
}
|
||||
|
||||
// News timestamps cross the Tauri boundary as strings and are rehydrated here for panel rendering.
|
||||
return {
|
||||
...payload,
|
||||
data: payload.data.map(
|
||||
(item): NewsItem => ({
|
||||
...item,
|
||||
timestamp: new Date(item.timestamp),
|
||||
}),
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
class TerminalBridge {
|
||||
private listenersReady: Promise<void> | null = null;
|
||||
private unlistenFns: UnlistenFn[] = [];
|
||||
private streamCallbacks = new Map<string, StreamCallbacks>();
|
||||
|
||||
private async ensureListeners() {
|
||||
if (this.listenersReady) {
|
||||
return this.listenersReady;
|
||||
}
|
||||
|
||||
this.listenersReady = Promise.all([
|
||||
// Route incremental stream events back to the workspace that initiated the request.
|
||||
listen<AgentDeltaEvent>('agent_delta', (event) => {
|
||||
const callbacks = this.streamCallbacks.get(event.payload.requestId);
|
||||
if (!callbacks || callbacks.workspaceId !== event.payload.workspaceId) {
|
||||
return;
|
||||
}
|
||||
callbacks.onDelta(event.payload);
|
||||
}),
|
||||
listen<AgentResultEvent>('agent_result', (event) => {
|
||||
const callbacks = this.streamCallbacks.get(event.payload.requestId);
|
||||
if (!callbacks || callbacks.workspaceId !== event.payload.workspaceId) {
|
||||
return;
|
||||
}
|
||||
callbacks.onResult(event.payload);
|
||||
this.streamCallbacks.delete(event.payload.requestId);
|
||||
}),
|
||||
listen<AgentErrorEvent>('agent_error', (event) => {
|
||||
const callbacks = this.streamCallbacks.get(event.payload.requestId);
|
||||
if (!callbacks || callbacks.workspaceId !== event.payload.workspaceId) {
|
||||
return;
|
||||
}
|
||||
callbacks.onError(event.payload);
|
||||
this.streamCallbacks.delete(event.payload.requestId);
|
||||
}),
|
||||
]).then((unlistenFns) => {
|
||||
this.unlistenFns = unlistenFns;
|
||||
});
|
||||
|
||||
return this.listenersReady;
|
||||
}
|
||||
|
||||
async executeTerminalCommand(
|
||||
request: ExecuteTerminalCommandRequest,
|
||||
): Promise<ResolvedTerminalCommandResponse> {
|
||||
const response = await invoke<TerminalCommandResponse>('execute_terminal_command', {
|
||||
request,
|
||||
});
|
||||
|
||||
if (response.kind === 'text') {
|
||||
return response;
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'panel',
|
||||
panel: deserializePanelPayload(response.panel),
|
||||
};
|
||||
}
|
||||
|
||||
async startChatStream(
|
||||
request: StartChatStreamRequest,
|
||||
callbacks: Omit<StreamCallbacks, 'workspaceId'>,
|
||||
): Promise<ChatStreamStart> {
|
||||
await this.ensureListeners();
|
||||
|
||||
const start = await invoke<ChatStreamStart>('start_chat_stream', {
|
||||
request,
|
||||
});
|
||||
|
||||
// Register callbacks after the backend returns the request id used by subsequent stream events.
|
||||
this.streamCallbacks.set(start.requestId, {
|
||||
workspaceId: request.workspaceId,
|
||||
...callbacks,
|
||||
});
|
||||
|
||||
return start;
|
||||
}
|
||||
|
||||
async dispose() {
|
||||
for (const unlisten of this.unlistenFns) {
|
||||
await unlisten();
|
||||
}
|
||||
this.unlistenFns = [];
|
||||
this.streamCallbacks.clear();
|
||||
this.listenersReady = null;
|
||||
}
|
||||
}
|
||||
|
||||
export const terminalBridge = new TerminalBridge();
|
||||
9
MosaicIQ/src/main.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
274
MosaicIQ/src/shared/mock-financial-data.json
Normal file
@@ -0,0 +1,274 @@
|
||||
{
|
||||
"companies": [
|
||||
{
|
||||
"symbol": "AAPL",
|
||||
"name": "Apple Inc.",
|
||||
"price": 178.72,
|
||||
"change": 2.34,
|
||||
"changePercent": 1.33,
|
||||
"marketCap": 2800000000000,
|
||||
"volume": 52340000,
|
||||
"pe": 28.5,
|
||||
"eps": 6.27,
|
||||
"high52Week": 199.62,
|
||||
"low52Week": 124.17
|
||||
},
|
||||
{
|
||||
"symbol": "TSLA",
|
||||
"name": "Tesla, Inc.",
|
||||
"price": 248.5,
|
||||
"change": -5.8,
|
||||
"changePercent": -2.28,
|
||||
"marketCap": 790000000000,
|
||||
"volume": 112000000,
|
||||
"pe": 72.3,
|
||||
"eps": 3.44,
|
||||
"high52Week": 299.29,
|
||||
"low52Week": 152.37
|
||||
},
|
||||
{
|
||||
"symbol": "NVDA",
|
||||
"name": "NVIDIA Corporation",
|
||||
"price": 875.28,
|
||||
"change": 18.45,
|
||||
"changePercent": 2.15,
|
||||
"marketCap": 2160000000000,
|
||||
"volume": 45600000,
|
||||
"pe": 65.2,
|
||||
"eps": 13.42,
|
||||
"high52Week": 950.11,
|
||||
"low52Week": 262.2
|
||||
},
|
||||
{
|
||||
"symbol": "MSFT",
|
||||
"name": "Microsoft Corporation",
|
||||
"price": 378.91,
|
||||
"change": 4.23,
|
||||
"changePercent": 1.13,
|
||||
"marketCap": 2810000000000,
|
||||
"volume": 22300000,
|
||||
"pe": 35.8,
|
||||
"eps": 10.58,
|
||||
"high52Week": 420.82,
|
||||
"low52Week": 245.61
|
||||
},
|
||||
{
|
||||
"symbol": "GOOGL",
|
||||
"name": "Alphabet Inc.",
|
||||
"price": 141.8,
|
||||
"change": 1.56,
|
||||
"changePercent": 1.11,
|
||||
"marketCap": 1780000000000,
|
||||
"volume": 28900000,
|
||||
"pe": 24.7,
|
||||
"eps": 5.74,
|
||||
"high52Week": 151.55,
|
||||
"low52Week": 83.34
|
||||
},
|
||||
{
|
||||
"symbol": "AMZN",
|
||||
"name": "Amazon.com, Inc.",
|
||||
"price": 178.25,
|
||||
"change": 3.12,
|
||||
"changePercent": 1.78,
|
||||
"marketCap": 1850000000000,
|
||||
"volume": 45200000,
|
||||
"pe": 62.4,
|
||||
"eps": 2.86,
|
||||
"high52Week": 189.77,
|
||||
"low52Week": 95.47
|
||||
},
|
||||
{
|
||||
"symbol": "META",
|
||||
"name": "Meta Platforms, Inc.",
|
||||
"price": 505.95,
|
||||
"change": 8.92,
|
||||
"changePercent": 1.8,
|
||||
"marketCap": 1300000000000,
|
||||
"volume": 15600000,
|
||||
"pe": 33.1,
|
||||
"eps": 15.28,
|
||||
"high52Week": 542.81,
|
||||
"low52Week": 167.61
|
||||
}
|
||||
],
|
||||
"portfolio": {
|
||||
"holdings": [
|
||||
{
|
||||
"symbol": "AAPL",
|
||||
"name": "Apple Inc.",
|
||||
"quantity": 50,
|
||||
"avgCost": 165,
|
||||
"currentPrice": 178.72,
|
||||
"currentValue": 8936,
|
||||
"gainLoss": 686,
|
||||
"gainLossPercent": 8.31
|
||||
},
|
||||
{
|
||||
"symbol": "NVDA",
|
||||
"name": "NVIDIA Corporation",
|
||||
"quantity": 25,
|
||||
"avgCost": 650,
|
||||
"currentPrice": 875.28,
|
||||
"currentValue": 21882,
|
||||
"gainLoss": 5632,
|
||||
"gainLossPercent": 34.66
|
||||
},
|
||||
{
|
||||
"symbol": "MSFT",
|
||||
"name": "Microsoft Corporation",
|
||||
"quantity": 30,
|
||||
"avgCost": 380,
|
||||
"currentPrice": 378.91,
|
||||
"currentValue": 11367.3,
|
||||
"gainLoss": -32.7,
|
||||
"gainLossPercent": -0.29
|
||||
},
|
||||
{
|
||||
"symbol": "GOOGL",
|
||||
"name": "Alphabet Inc.",
|
||||
"quantity": 40,
|
||||
"avgCost": 135,
|
||||
"currentPrice": 141.8,
|
||||
"currentValue": 5672,
|
||||
"gainLoss": 272,
|
||||
"gainLossPercent": 5.04
|
||||
}
|
||||
],
|
||||
"totalValue": 47857.3,
|
||||
"dayChange": 487.35,
|
||||
"dayChangePercent": 1.03,
|
||||
"totalGain": 6557.3,
|
||||
"totalGainPercent": 15.86
|
||||
},
|
||||
"newsItems": [
|
||||
{
|
||||
"id": "1",
|
||||
"source": "Bloomberg",
|
||||
"headline": "Apple Reports Record Q4 Earnings, Stock Surges",
|
||||
"timestamp": "2026-04-01T11:00:00Z",
|
||||
"snippet": "Apple Inc. reported better-than-expected quarterly earnings driven by strong iPhone 15 sales and growing services revenue...",
|
||||
"relatedTickers": ["AAPL"]
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"source": "Reuters",
|
||||
"headline": "NVIDIA Announces New AI Chip Partnerships",
|
||||
"timestamp": "2026-04-01T10:00:00Z",
|
||||
"snippet": "NVIDIA revealed partnerships with major cloud providers for its next-generation AI chips, sending shares to new highs...",
|
||||
"relatedTickers": ["NVDA"]
|
||||
},
|
||||
{
|
||||
"id": "3",
|
||||
"source": "CNBC",
|
||||
"headline": "Fed Signals Potential Rate Cuts in 2025",
|
||||
"timestamp": "2026-04-01T09:00:00Z",
|
||||
"snippet": "Federal Reserve officials indicated they may begin cutting interest rates in the first half of 2025, citing cooling inflation...",
|
||||
"relatedTickers": []
|
||||
},
|
||||
{
|
||||
"id": "4",
|
||||
"source": "Wall Street Journal",
|
||||
"headline": "Tesla Faces Increased Competition in EV Market",
|
||||
"timestamp": "2026-04-01T08:00:00Z",
|
||||
"snippet": "Traditional automakers are gaining ground in the electric vehicle market, putting pressure on Tesla's market share...",
|
||||
"relatedTickers": ["TSLA"]
|
||||
},
|
||||
{
|
||||
"id": "5",
|
||||
"source": "Financial Times",
|
||||
"headline": "Microsoft Cloud Growth Beats Estimates",
|
||||
"timestamp": "2026-04-01T07:00:00Z",
|
||||
"snippet": "Microsoft's Azure cloud platform grew 29% year-over-year, driven by AI infrastructure demand from enterprise customers...",
|
||||
"relatedTickers": ["MSFT"]
|
||||
},
|
||||
{
|
||||
"id": "6",
|
||||
"source": "TechCrunch",
|
||||
"headline": "Google Unveils New Gemini AI Features",
|
||||
"timestamp": "2026-04-01T06:00:00Z",
|
||||
"snippet": "Alphabet announced significant updates to its Gemini AI model, including enhanced reasoning capabilities and multimodal understanding...",
|
||||
"relatedTickers": ["GOOGL"]
|
||||
}
|
||||
],
|
||||
"analyses": {
|
||||
"AAPL": {
|
||||
"symbol": "AAPL",
|
||||
"summary": "Apple shows strong fundamentals with robust iPhone 15 sales momentum and growing services revenue. The stock appears reasonably valued considering its growth prospects.",
|
||||
"sentiment": "bullish",
|
||||
"keyPoints": [
|
||||
"Strong Q4 earnings beat expectations",
|
||||
"Services segment growing 16% YoY",
|
||||
"iPhone 15 seeing strong demand in China",
|
||||
"Shareholder returns through dividends and buybacks",
|
||||
"Healthy balance sheet with $56B in cash"
|
||||
],
|
||||
"risks": [
|
||||
"China market dependency",
|
||||
"Slow growth in Mac and iPad segments",
|
||||
"Regulatory scrutiny in EU",
|
||||
"Competition in services space"
|
||||
],
|
||||
"opportunities": [
|
||||
"AI integration across product line",
|
||||
"AR/VR headset market potential",
|
||||
"Expansion in emerging markets",
|
||||
"Health technology initiatives"
|
||||
],
|
||||
"recommendation": "buy",
|
||||
"targetPrice": 195
|
||||
},
|
||||
"TSLA": {
|
||||
"symbol": "TSLA",
|
||||
"summary": "Tesla faces near-term headwinds from increased competition and pricing pressure. However, long-term opportunities in energy storage and autonomous driving remain significant.",
|
||||
"sentiment": "neutral",
|
||||
"keyPoints": [
|
||||
"EV market saturation in key regions",
|
||||
"Price cuts impacting margins",
|
||||
"Energy storage business growing rapidly",
|
||||
"Full Self-Driving progress continues",
|
||||
"Cybertruck ramp-up proceeding slowly"
|
||||
],
|
||||
"risks": [
|
||||
"Intensifying competition from BYD, others",
|
||||
"Margin compression from price cuts",
|
||||
"Elon Musk distraction risk",
|
||||
"Regulatory challenges for FSD"
|
||||
],
|
||||
"opportunities": [
|
||||
"Autonomous riding network potential",
|
||||
"Energy storage and solar expansion",
|
||||
"Optimus robot development",
|
||||
"International market expansion"
|
||||
],
|
||||
"recommendation": "hold",
|
||||
"targetPrice": 265
|
||||
},
|
||||
"NVDA": {
|
||||
"symbol": "NVDA",
|
||||
"summary": "NVIDIA dominates the AI chip market with exceptional growth prospects. The stock trades at a premium but may be justified given its competitive positioning.",
|
||||
"sentiment": "bullish",
|
||||
"keyPoints": [
|
||||
"AI infrastructure demand exploding",
|
||||
"H100/H200 chips sold out through 2025",
|
||||
"Data center revenue up 427% YoY",
|
||||
"Software ecosystem creating lock-in",
|
||||
"New Blackwell architecture launching soon"
|
||||
],
|
||||
"risks": [
|
||||
"Very high valuation multiples",
|
||||
"Competition from AMD, custom chips",
|
||||
"AI spending cycle may slow",
|
||||
"Export restrictions to China"
|
||||
],
|
||||
"opportunities": [
|
||||
"New AI model training demands",
|
||||
"Enterprise AI adoption",
|
||||
"Automotive and robotics chips",
|
||||
"Sofware and services revenue growth"
|
||||
],
|
||||
"recommendation": "buy",
|
||||
"targetPrice": 950
|
||||
}
|
||||
}
|
||||
}
|
||||
65
MosaicIQ/src/types/financial.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
export interface Company {
|
||||
symbol: string;
|
||||
name: string;
|
||||
price: number;
|
||||
change: number;
|
||||
changePercent: number;
|
||||
marketCap: number;
|
||||
volume: number;
|
||||
pe?: number;
|
||||
eps?: number;
|
||||
high52Week?: number;
|
||||
low52Week?: number;
|
||||
}
|
||||
|
||||
export interface Holding {
|
||||
symbol: string;
|
||||
name: string;
|
||||
quantity: number;
|
||||
avgCost: number;
|
||||
currentPrice: number;
|
||||
currentValue: number;
|
||||
gainLoss: number;
|
||||
gainLossPercent: number;
|
||||
}
|
||||
|
||||
export interface Portfolio {
|
||||
holdings: Holding[];
|
||||
totalValue: number;
|
||||
dayChange: number;
|
||||
dayChangePercent: number;
|
||||
totalGain: number;
|
||||
totalGainPercent: number;
|
||||
}
|
||||
|
||||
export interface NewsItem {
|
||||
id: string;
|
||||
source: string;
|
||||
headline: string;
|
||||
timestamp: Date;
|
||||
snippet: string;
|
||||
url?: string;
|
||||
relatedTickers: string[];
|
||||
}
|
||||
|
||||
export interface StockAnalysis {
|
||||
symbol: string;
|
||||
summary: string;
|
||||
sentiment: 'bullish' | 'bearish' | 'neutral';
|
||||
keyPoints: string[];
|
||||
risks: string[];
|
||||
opportunities: string[];
|
||||
recommendation: 'buy' | 'sell' | 'hold';
|
||||
targetPrice?: number;
|
||||
}
|
||||
|
||||
export interface SerializedNewsItem extends Omit<NewsItem, 'timestamp'> {
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface MockFinancialData {
|
||||
companies: Company[];
|
||||
portfolio: Portfolio;
|
||||
newsItems: SerializedNewsItem[];
|
||||
analyses: Record<string, StockAnalysis>;
|
||||
}
|
||||
77
MosaicIQ/src/types/terminal.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { Company, NewsItem, Portfolio, SerializedNewsItem, StockAnalysis } from './financial';
|
||||
|
||||
export type PanelPayload =
|
||||
| { type: 'company'; data: Company }
|
||||
| { type: 'portfolio'; data: Portfolio }
|
||||
| { type: 'news'; data: NewsItem[]; ticker?: string }
|
||||
| { type: 'analysis'; data: StockAnalysis };
|
||||
|
||||
export type TransportPanelPayload =
|
||||
| { type: 'company'; data: Company }
|
||||
| { type: 'portfolio'; data: Portfolio }
|
||||
| { type: 'news'; data: SerializedNewsItem[]; ticker?: string }
|
||||
| { type: 'analysis'; data: StockAnalysis };
|
||||
|
||||
export type TerminalCommandResponse =
|
||||
| { kind: 'text'; content: string }
|
||||
| { kind: 'panel'; panel: TransportPanelPayload };
|
||||
|
||||
export type ResolvedTerminalCommandResponse =
|
||||
| { kind: 'text'; content: string }
|
||||
| { kind: 'panel'; panel: PanelPayload };
|
||||
|
||||
export interface ExecuteTerminalCommandRequest {
|
||||
workspaceId: string;
|
||||
input: string;
|
||||
}
|
||||
|
||||
export interface StartChatStreamRequest {
|
||||
workspaceId: string;
|
||||
sessionId?: string;
|
||||
prompt: string;
|
||||
}
|
||||
|
||||
export interface ChatStreamStart {
|
||||
requestId: string;
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
export interface AgentDeltaEvent {
|
||||
workspaceId: string;
|
||||
requestId: string;
|
||||
sessionId: string;
|
||||
delta: string;
|
||||
}
|
||||
|
||||
export interface AgentResultEvent {
|
||||
workspaceId: string;
|
||||
requestId: string;
|
||||
sessionId: string;
|
||||
reply: string;
|
||||
}
|
||||
|
||||
export interface AgentErrorEvent {
|
||||
workspaceId: string;
|
||||
requestId: string;
|
||||
sessionId: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface TerminalEntry {
|
||||
id: string;
|
||||
type: 'command' | 'response' | 'system' | 'error' | 'panel';
|
||||
content: string | PanelPayload;
|
||||
timestamp?: Date;
|
||||
}
|
||||
|
||||
export interface CommandSuggestion {
|
||||
command: string;
|
||||
description: string;
|
||||
category: 'search' | 'portfolio' | 'news' | 'analysis' | 'system';
|
||||
}
|
||||
|
||||
export interface TerminalState {
|
||||
history: TerminalEntry[];
|
||||
currentIndex: number;
|
||||
isProcessing: boolean;
|
||||
}
|
||||
1
MosaicIQ/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
25
MosaicIQ/tsconfig.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
10
MosaicIQ/tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
33
MosaicIQ/vite.config.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
|
||||
// @ts-expect-error process is a nodejs global
|
||||
const host = process.env.TAURI_DEV_HOST;
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig(async () => ({
|
||||
plugins: [react(), tailwindcss()],
|
||||
|
||||
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
|
||||
//
|
||||
// 1. prevent Vite from obscuring rust errors
|
||||
clearScreen: false,
|
||||
// 2. tauri expects a fixed port, fail if that port is not available
|
||||
server: {
|
||||
port: 1420,
|
||||
strictPort: true,
|
||||
host: host || false,
|
||||
hmr: host
|
||||
? {
|
||||
protocol: "ws",
|
||||
host,
|
||||
port: 1421,
|
||||
}
|
||||
: undefined,
|
||||
watch: {
|
||||
// 3. tell Vite to ignore watching `src-tauri`
|
||||
ignored: ["**/src-tauri/**"],
|
||||
},
|
||||
},
|
||||
}));
|
||||